[ADD] product_pricelist_total_margin: New module for additive margin calculation

- Add use_total_margin field to pricelist items
- Override _compute_price() to sum margins additively instead of compounding
- Support chained pricelists with custom base types (last_purchase_price)
- Add global minimum and maximum margin limits configuration
- Store limits in ir.config_parameter via res.config.settings
- Apply global limits after total margin calculation
- Add comprehensive test suite (9 tests) covering:
  * Basic additive vs compound margin behavior
  * Three-level pricelist chains
  * Global minimum/maximum margin enforcement
  * Rounding and surcharge compatibility
- Add configuration UI in Settings > Sales
- All tests passing (9/9)

This module fixes the issue where chained pricelists were compounding margins
instead of calculating total margins. Example: base 4.68€ with -5% and +25%
now correctly results in 5.616€ (20% total) instead of 5.56€ (compound).
This commit is contained in:
snt 2026-02-21 16:11:13 +01:00
parent f35bf0c5a1
commit 32f345bc44
12 changed files with 1143 additions and 0 deletions

View file

@ -0,0 +1,5 @@
# Copyright 2026 - Today Kidekoop
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import product_pricelist_item # noqa: F401
from . import res_config_settings # noqa: F401

View file

@ -0,0 +1,386 @@
# Copyright 2026 - Today Kidekoop
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import fields # type: ignore
from odoo import models # type: ignore
from odoo.tools import float_round # type: ignore
_logger = logging.getLogger(__name__)
class ProductPricelistItem(models.Model):
_inherit = "product.pricelist.item"
use_total_margin = fields.Boolean(
string="Total Margin Mode",
default=False,
help="If checked, margins will be accumulated additively across the pricelist "
"chain instead of being compounded.\n\n"
"Example: Base price 100€, Margin1 -5%, Margin2 25%\n"
"- Compound (default): 100 * 0.95 * 1.25 = 118.75€\n"
"- Total (this option): 100 * (1 + 0.20) = 120€",
)
def _get_base_price_and_margins(self, product, quantity, uom, date, currency):
"""
Traverse the pricelist chain to get the original base price and collect
all margins in the chain.
Returns:
tuple: (base_price: float, margins: list of floats)
"""
margins = []
current_item = self
visited_items = set()
_logger.info(
"[TOTAL MARGIN] Starting chain traversal for product %s [%s]",
product.default_code or product.name,
product.id,
)
# Traverse the chain backwards to collect all margins
while current_item:
# Prevent infinite loops
if current_item.id in visited_items:
_logger.warning(
"[TOTAL MARGIN] Circular reference detected in pricelist chain at item %s",
current_item.id,
)
break
visited_items.add(current_item.id)
# Collect this item's margin
if current_item.compute_price == "formula":
if current_item.base == "standard_price":
# For standard_price, use price_markup (inverted)
margin = current_item.price_markup or 0.0
else:
# For other bases, use price_discount (negative = markup)
margin = -(current_item.price_discount or 0.0)
margins.append(margin)
_logger.info(
"[TOTAL MARGIN] Item %s: base=%s, margin=%.2f%%",
current_item.id,
current_item.base,
margin,
)
# Move to next item in chain
if current_item.base == "pricelist" and current_item.base_pricelist_id:
# Get the applicable rule from the base pricelist
base_pricelist = current_item.base_pricelist_id
rules = base_pricelist._get_applicable_rules(
products=product,
date=date,
quantity=quantity,
uom=uom,
)
if rules:
# Get the first (highest priority) rule
current_item = rules[0]
else:
_logger.warning(
"[TOTAL MARGIN] No applicable rules found in base pricelist %s",
base_pricelist.id,
)
current_item = None
else:
# We've reached the base of the chain
break
# Now get the original base price (without any margins)
if current_item:
# Use the last item's base to compute the original price
base_price = self._compute_original_base_price(
current_item, product, quantity, uom, date, currency
)
_logger.info(
"[TOTAL MARGIN] Original base price: %.2f (from item %s, base=%s)",
base_price,
current_item.id,
current_item.base,
)
else:
# Fallback to product list price
base_price = product.lst_price
_logger.warning(
"[TOTAL MARGIN] Could not find base item, using product.lst_price: %.2f",
base_price,
)
# Reverse margins list since we collected them backwards
margins.reverse()
return base_price, margins
def _compute_original_base_price(
self, item, product, quantity, uom, date, currency
):
"""
Compute the original base price from a pricelist item without applying
any margin formula.
Args:
item: The pricelist item at the base of the chain
product: The product to price
quantity: Quantity
uom: Unit of measure
date: Date for pricing
currency: Target currency
Returns:
float: The base price before any margins
"""
rule_base = item.base or "list_price"
# Handle custom base from product_sale_price_from_pricelist
if rule_base == "last_purchase_price":
src_currency = product.currency_id
price = product.last_purchase_price_received or 0.0
_logger.info("[TOTAL MARGIN] Using last_purchase_price: %.2f", price)
elif rule_base == "standard_price":
src_currency = product.cost_currency_id
price = product.standard_price or 0.0
_logger.info("[TOTAL MARGIN] Using standard_price: %.2f", price)
elif rule_base == "pricelist" and item.base_pricelist_id:
# This shouldn't happen if we traversed correctly, but handle it
_logger.warning(
"[TOTAL MARGIN] Unexpected pricelist base at bottom of chain"
)
src_currency = item.base_pricelist_id.currency_id
price = item.base_pricelist_id._get_product_price(
product=product,
quantity=quantity,
currency=src_currency,
uom=uom,
date=date,
)
else: # list_price (default)
src_currency = product.currency_id
price = product.lst_price or 0.0
_logger.info("[TOTAL MARGIN] Using list_price: %.2f", price)
# Convert currency if needed
if src_currency and currency and src_currency != currency:
company = self.env.company
price = src_currency._convert(
price,
currency,
company,
date or fields.Date.today(),
)
_logger.info(
"[TOTAL MARGIN] Converted price from %s to %s: %.2f",
src_currency.name,
currency.name,
price,
)
return price
def _apply_formula_extras(self, price, base_price, currency):
"""
Apply price_round, price_surcharge, and min/max margins to the calculated price.
Args:
price: The price after margin is applied
base_price: The original base price (for min/max margin calculation)
currency: Currency for conversions
Returns:
float: The final price with all extras applied
"""
# Rounding
if self.price_round:
price = float_round(price, precision_rounding=self.price_round)
_logger.info("[TOTAL MARGIN] After rounding: %.2f", price)
# Surcharge
if self.price_surcharge:
surcharge = self.price_surcharge
if self.currency_id and currency and self.currency_id != currency:
company = self.env.company
surcharge = self.currency_id._convert(
surcharge,
currency,
company,
fields.Date.today(),
)
price += surcharge
_logger.info(
"[TOTAL MARGIN] After surcharge (+%.2f): %.2f", surcharge, price
)
# Min margin
if self.price_min_margin:
min_margin = self.price_min_margin
if self.currency_id and currency and self.currency_id != currency:
company = self.env.company
min_margin = self.currency_id._convert(
min_margin,
currency,
company,
fields.Date.today(),
)
min_price = base_price + min_margin
if price < min_price:
_logger.info(
"[TOTAL MARGIN] Applying min_margin: %.2f -> %.2f",
price,
min_price,
)
price = min_price
# Max margin
if self.price_max_margin:
max_margin = self.price_max_margin
if self.currency_id and currency and self.currency_id != currency:
company = self.env.company
max_margin = self.currency_id._convert(
max_margin,
currency,
company,
fields.Date.today(),
)
max_price = base_price + max_margin
if price > max_price:
_logger.info(
"[TOTAL MARGIN] Applying max_margin: %.2f -> %.2f",
price,
max_price,
)
price = max_price
return price
def _apply_global_margin_limits(self, total_margin, base_price):
"""
Apply global minimum and maximum margin limits from configuration.
Args:
total_margin: The calculated total margin percentage
base_price: The base price (for logging)
Returns:
float: The adjusted margin percentage
"""
# Get global margin limits from configuration
IrConfigParam = self.env["ir.config_parameter"].sudo()
min_margin = float(
IrConfigParam.get_param(
"product_pricelist_total_margin.min_percent", default="0.0"
)
)
max_margin = float(
IrConfigParam.get_param(
"product_pricelist_total_margin.max_percent", default="0.0"
)
)
original_margin = total_margin
# Apply minimum margin if configured (> 0)
if min_margin > 0.0 and total_margin < min_margin:
total_margin = min_margin
_logger.info(
"[TOTAL MARGIN] Applied global minimum margin: %.2f%% -> %.2f%% "
"(configured min: %.2f%%)",
original_margin,
total_margin,
min_margin,
)
# Apply maximum margin if configured (> 0)
if max_margin > 0.0 and total_margin > max_margin:
total_margin = max_margin
_logger.info(
"[TOTAL MARGIN] Applied global maximum margin: %.2f%% -> %.2f%% "
"(configured max: %.2f%%)",
original_margin,
total_margin,
max_margin,
)
return total_margin
def _compute_price(
self,
product,
quantity,
uom,
date,
currency=None,
):
"""
Override to implement total margin calculation when use_total_margin is True.
Instead of compounding margins (applying each margin on top of the previous
result), this method:
1. Traverses the pricelist chain to collect all margins
2. Sums them additively
3. Applies the total margin to the original base price
4. Enforces global min/max margin limits if configured
Example:
Base price: 100
Pricelist 1: -5% discount
Pricelist 2: 25% markup
Standard Odoo (compound): 100 * 0.95 * 1.25 = 118.75
This module (total): 100 * (1 + 0.20) = 120
"""
# Only apply total margin logic if:
# 1. use_total_margin is True
# 2. compute_price is 'formula' (not 'fixed' or 'percentage')
# 3. We're in a pricelist chain (base='pricelist')
if (
self.use_total_margin
and self.compute_price == "formula"
and self.base == "pricelist"
):
_logger.info(
"[TOTAL MARGIN] Computing total margin for product %s [%s]",
product.default_code or product.name,
product.id,
)
# Get base price and all margins in the chain
base_price, margins = self._get_base_price_and_margins(
product, quantity, uom, date, currency
)
# Sum margins additively
total_margin = sum(margins)
_logger.info(
"[TOTAL MARGIN] Margins: %s = %.2f%% total",
[f"{m:.2f}%" for m in margins],
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
price = base_price * (1 + total_margin / 100)
_logger.info(
"[TOTAL MARGIN] Base price %.2f * (1 + %.2f%%) = %.2f",
base_price,
total_margin,
price,
)
# Apply formula extras (round, surcharge, min/max margins)
price = self._apply_formula_extras(price, base_price, currency)
_logger.info("[TOTAL MARGIN] Final price: %.2f", price)
return price
# Standard behavior for all other cases
return super()._compute_price(product, quantity, uom, date, currency)

View file

@ -0,0 +1,33 @@
# Copyright 2026 - Today Kidekoop
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields # type: ignore
from odoo import models # type: ignore
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
total_margin_min_percent = fields.Float(
string="Minimum Total Margin (%)",
default=0.0,
help="Minimum total margin percentage allowed for pricelist items using "
"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"
"Set to 0 to disable minimum margin control.",
config_parameter="product_pricelist_total_margin.min_percent",
)
total_margin_max_percent = fields.Float(
string="Maximum Total Margin (%)",
default=0.0,
help="Maximum total margin percentage allowed for pricelist items using "
"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"
"Set to 0 to disable maximum margin control.",
config_parameter="product_pricelist_total_margin.max_percent",
)