[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:
parent
f35bf0c5a1
commit
32f345bc44
12 changed files with 1143 additions and 0 deletions
5
product_pricelist_total_margin/models/__init__.py
Normal file
5
product_pricelist_total_margin/models/__init__.py
Normal 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
|
||||
386
product_pricelist_total_margin/models/product_pricelist_item.py
Normal file
386
product_pricelist_total_margin/models/product_pricelist_item.py
Normal 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)
|
||||
33
product_pricelist_total_margin/models/res_config_settings.py
Normal file
33
product_pricelist_total_margin/models/res_config_settings.py
Normal 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",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue