addons-cm/product_pricelist_total_margin
snt b31df7b9d8 [DOC] product_pricelist_total_margin: Update docs and version to 18.0.1.2.0
Changelog:
- Document global_margin_type feature for independent global limits calculation
- Update version from 18.0.1.1.0 to 18.0.1.2.0
- Update test coverage count (11 → 13 tests)
- Update manifest summary to include global limits enforcement
2026-02-21 19:19:25 +01:00
..
models [IMP] product_pricelist_total_margin: Add global_margin_type for min/max limits 2026-02-21 19:16:16 +01:00
tests [IMP] product_pricelist_total_margin: Add global_margin_type for min/max limits 2026-02-21 19:16:16 +01:00
views [IMP] product_pricelist_total_margin: Add global_margin_type for min/max limits 2026-02-21 19:16:16 +01:00
__init__.py [ADD] product_pricelist_total_margin: New module for additive margin calculation 2026-02-21 16:11:13 +01:00
__manifest__.py [DOC] product_pricelist_total_margin: Update docs and version to 18.0.1.2.0 2026-02-21 19:19:25 +01:00
README.md [DOC] product_pricelist_total_margin: Update docs and version to 18.0.1.2.0 2026-02-21 19:19:25 +01:00

Product Pricelist Total Margin

Overview

This module solves the problem of compounded margins when using chained pricelists in Odoo. By default, Odoo applies each pricelist rule's margin on top of the previous result, leading to compounded percentages. This module provides an option to calculate margins additively instead.

Problem Description

Standard Odoo Behavior (Compound Margins)

When you chain pricelists (Pricelist A → Pricelist B), Odoo applies margins in cascade:

Base price: 4.68€
Pricelist A: -5% discount → 4.68 × 0.95 = 4.446€
Pricelist B: 25% markup → 4.446 × 1.25 = 5.5575€

Result: 5.56€ (effective margin: 18.8%)

Desired Behavior (Total/Additive Margins)

With this module, you can calculate the total margin by summing percentages:

Option 1: Markup (on cost)

Base price: 4.68€
Total margin: -5% + 25% = 20%
Final price: 4.68 × 1.20 = 5.616€
Effective margin: (5.616 - 4.68) / 5.616 = 16.67%

Result: 5.62€ (20% markup on cost)

Option 2: Commercial Margin (on PVP)

Base price: 4.68€
Total margin: -5% + 25% = 20%
Final price: 4.68 / 0.80 = 5.85€
Effective margin: (5.85 - 4.68) / 5.85 = 20%

Result: 5.85€ (20% commercial margin on PVP)

Features

  • Additive margin calculation across chained pricelists
  • Two calculation methods per pricelist:
    • Markup (on cost): PVP = Cost × (1 + markup%)
    • Commercial Margin (on PVP): PVP = Cost / (1 - margin%)
  • Global min/max margin limits with independent calculation type:
    • Configure minimum and maximum margin percentages
    • Choose between Markup or Commercial Margin interpretation for limits
    • Limits can use different calculation method than individual pricelists
  • Opt-in via checkbox - doesn't affect existing pricelists
  • Compatible with custom bases (last_purchase_price from product_sale_price_from_pricelist)
  • Supports all formula extras (price_round, price_surcharge, price_min/max_margin)
  • Multi-level chains - works with 2+ pricelists in sequence
  • Currency conversion - handles multi-currency scenarios
  • Detailed logging - debug pricing calculations easily

Installation

  1. Install dependencies:

    # Ensure these modules are installed:
    - product
    - product_price_category
    - product_sale_price_from_pricelist
    
  2. Install module:

    docker-compose exec odoo odoo -d odoo -u product_pricelist_total_margin --stop-after-init
    
  3. Restart Odoo:

    docker-compose restart odoo
    

Configuration

Step 1: Create Base Pricelist

Create a pricelist that defines your base pricing logic:

  1. Go to Sales > Configuration > Pricelists
  2. Create a new pricelist: "Base Pricelist - Last Purchase Price"
  3. Add a rule:
    • Apply On: All Products
    • Based on: Last Purchase Price (or List Price, Standard Price, etc.)
    • Price Computation: Formula
    • Discount: 5% (for "cesta básica" category example)

Step 2: Create Chained Pricelist

Create a pricelist that chains to the base one:

  1. Create a new pricelist: "Category Margin - Repostería"
  2. Add a rule:
    • Apply On: All Products (or specific category)
    • Based on: Other Pricelist → Select "Base Pricelist - Last Purchase Price"
    • Price Computation: Formula
    • Discount: -25% (negative = 25% markup)
    • ☑️ Total Margin Mode: Check this box!
    • Calculation Method: Choose between:
      • Markup (on cost) - Default, calculates PVP = Cost × (1 + markup%)
      • Commercial Margin (on PVP) - Calculates PVP = Cost / (1 - margin%)

Step 3: Configure Global Limits (Optional)

Set enterprise-wide minimum and maximum margins:

  1. Go to Settings > Sales > Total Margin Limits
  2. Configure:
    • Minimum Margin (%): E.g., 10% (no product can have less margin)
    • Maximum Margin (%): E.g., 50% (no product can have more margin)
    • Calculation Method: Choose how these percentages are interpreted:
      • Markup (on cost) - Default, Min: Cost × 1.10, Max: Cost × 1.50
      • Commercial Margin (on PVP) - Min: Cost / 0.90, Max: Cost / 0.50

Important: The global limits calculation method is independent from individual pricelist items. You can have:

  • Items using Markup, with global limits as Commercial Margin
  • Items using Commercial Margin, with global limits as Markup
  • Both using the same method

Step 4: Assign to Products

  • For automatic price calculation, configure the pricelist in Settings > Sales > Automatic Price Configuration
  • Or assign the pricelist to specific customers/partners

Usage Example

Scenario: Cooperative Pricing System

Your cooperative has two margin rules:

  1. Price Category Discount: "Cesta Básica" products get -5% (to make them affordable)
  2. Product Category Markup: "Repostería" products get +25% (higher margin category)

Without this module (compound):

Product: Flour (cesta básica, repostería)
Purchase price: 4.68€
After price category (-5%): 4.446€
After product category (+25%): 5.5575€ ❌ Wrong effective margin: 18.8%

With this module (total margin enabled):

Product: Flour (cesta básica, repostería)
Purchase price: 4.68€
Total margin: -5% + 25% = 20%

Option 1 - Markup:
  Final price: 4.68 × 1.20 = 5.616€ ✅ Markup 20% on cost

Option 2 - Commercial Margin:
  Final price: 4.68 / 0.80 = 5.85€ ✅ Margin 20% on PVP

Which method to choose?

  • Markup (on cost): Traditional markup calculation, margin on cost base
  • Commercial Margin (on PVP): Retail/commercial margin, ensures exact margin percentage on final price

Technical Details

New Fields

  • Model: product.pricelist.item

use_total_margin (Boolean)

  • Default: False (opt-in)
  • Visibility: Only shown when:
    • compute_price = 'formula'
    • base = 'pricelist' (chained pricelist)
  • Purpose: Enable additive margin calculation instead of compound

margin_type (Selection)

  • Default: 'markup'
  • Options:
    • 'markup' - Markup (on cost): PVP = Cost × (1 + markup%)
    • 'margin' - Commercial Margin (on PVP): PVP = Cost / (1 - margin%)
  • Visibility: Only shown when use_total_margin = True
  • Purpose: Choose the calculation method for the total margin

Global Configuration Fields

  • Model: res.config.settings

total_margin_min_percent (Float)

  • Default: 0.0 (disabled)
  • Purpose: Minimum margin percentage for all products using Total Margin Mode
  • Interpretation: Depends on global_margin_type setting

total_margin_max_percent (Float)

  • Default: 0.0 (disabled)
  • Purpose: Maximum margin percentage for all products using Total Margin Mode
  • Interpretation: Depends on global_margin_type setting

global_margin_type (Selection)

  • Default: 'markup'
  • Options:
    • 'markup' - Limits are interpreted as markup on cost
    • 'margin' - Limits are interpreted as commercial margin on PVP
  • Purpose: Define how min/max percentages are converted to prices
  • Example: Min 25%:
    • Markup: Min Price = Cost × 1.25
    • Commercial Margin: Min Price = Cost / 0.75 (ensures 25% margin on final price)

Methods

_get_base_price_and_margins(product, quantity, uom, date, currency)

Traverses the pricelist chain backwards to:

  1. Find the original base price (before any margins)
  2. Collect all margin percentages along the chain

Returns: (base_price: float, margins: list[float])

_compute_original_base_price(item, product, quantity, uom, date, currency)

Computes the base price from the bottom item of the chain, supporting:

  • last_purchase_price (custom from product_sale_price_from_pricelist)
  • list_price (standard)
  • standard_price (cost)
  • Currency conversions

_apply_formula_extras(price, base_price, currency)

Applies additional formula options:

  • price_round: Round to nearest value
  • price_surcharge: Add fixed amount
  • price_min_margin: Enforce minimum margin
  • price_max_margin: Enforce maximum margin

_apply_global_margin_limits(price, base_price)

Enforces global minimum and maximum margins configured in Settings:

  1. Reads total_margin_min_percent, total_margin_max_percent, and global_margin_type from configuration
  2. Calculates min/max allowed prices based on global_margin_type:
    • Markup: Price = Cost × (1 + margin%)
    • Commercial Margin: Price = Cost / (1 - margin%)
  3. Adjusts price if it falls outside configured limits
  4. Returns adjusted price

Note: Global limits are applied AFTER the item's margin calculation, and can use a different margin type than the item itself.

Example:

  • Item calculates 15% markup → Price = 100 × 1.15 = 115€
  • Global min is 20% commercial margin → Min Price = 100 / 0.80 = 125€
  • Result: Price adjusted from 115€ to 125€ to meet global minimum

_compute_price(product, quantity, uom, date, currency) [OVERRIDE]

Main override that:

  1. Checks if use_total_margin=True and conditions are met
  2. Calls helper methods to get base price and margins
  3. Sums margins additively: total_margin = sum(margins)
  4. Applies total margin using item's selected method:
    • Markup: price = base_price * (1 + total_margin / 100)
    • Commercial Margin: price = base_price / (1 - total_margin / 100)
  5. Applies global min/max margin limits (may use different margin type)
  6. Applies formula extras
  7. Falls back to standard behavior if conditions not met

Safety: Commercial margin >= 100% is capped at 99% to avoid division by zero

Logging

All calculations are logged with [TOTAL MARGIN] prefix for easy debugging:

_logger.info("[TOTAL MARGIN] Item %s: base=%s, margin=%.2f%%", ...)
_logger.info("[TOTAL MARGIN] Margins: ['5.0%', '25.0%'] = 20.0% total")

# Markup method:
_logger.info("[TOTAL MARGIN] Markup: Base 4.68 * (1 + 20.0%) = 5.616")

# Commercial Margin method:
_logger.info("[TOTAL MARGIN] Commercial Margin: Base 4.68 / (1 - 20.0%) = 5.85")

View logs:

docker-compose logs -f odoo | grep "TOTAL MARGIN"

Testing

Run Tests

docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_pricelist_total_margin

Test Coverage

  • Compound margin (default behavior preserved)
  • Total margin with Markup calculation
  • Total margin with Commercial Margin calculation
  • Formula extras (round, surcharge, min/max)
  • Global min/max margin limits with Markup type
  • Global min/max margin limits with Commercial Margin type
  • Mixed margin types (item vs global with different types)
  • 3-level pricelist chains
  • Different base types (last_purchase_price, list_price)
  • Currency conversions

Total: 13 tests, all passing

Compatibility

  • Odoo Version: 18.0
  • Python Version: 3.10+
  • Dependencies:
    • product (core)
    • product_price_category (OCA)
    • product_sale_price_from_pricelist (custom)

Limitations

  1. Only works with compute_price='formula': Fixed prices and percentage-based rules are not affected
  2. Circular references: The module detects and breaks circular pricelist chains, but logs a warning
  3. Performance: Traversing long pricelist chains may impact performance (though minimal in practice)

Troubleshooting

Margins still compound even with checkbox enabled

Check:

  1. Is compute_price set to "Formula"?
  2. Is base set to "Other Pricelist"?
  3. Is the checkbox actually checked and saved?
  4. Check logs for [TOTAL MARGIN] entries to see if logic is being triggered

Price is incorrect

Debug:

  1. Enable developer mode
  2. Check logs: docker-compose logs -f odoo | grep "TOTAL MARGIN"
  3. Verify:
    • Base price is correct
    • All margins are collected
    • Currency conversions are applied
    • Formula extras (round, surcharge) are expected

Checkbox not visible

Possible causes:

  • compute_price is not "Formula" (must be formula-based)
  • base is not "Other Pricelist" (no chain to traverse)
  • View not properly loaded (try reloading page or clearing browser cache)

Development

File Structure

product_pricelist_total_margin/
├── __init__.py
├── __manifest__.py
├── README.md
├── models/
│   ├── __init__.py
│   └── product_pricelist_item.py
├── tests/
│   ├── __init__.py
│   └── test_total_margin.py
└── views/
    └── product_pricelist_item_views.xml

Contributing

Follow OCA guidelines and project conventions defined in .github/copilot-instructions.md.

License

AGPL-3.0 or later

Author

Kidekoop - 2026

Changelog

18.0.1.2.0 (2026-02-21)

  • [IMP] Add global_margin_type field for independent global limits calculation
    • Configure how min/max percentages are interpreted (Markup or Commercial Margin)
    • Global limits can use different calculation method than individual pricelist items
    • Enables flexible business rules (e.g., items use Markup, global limits enforce Commercial Margin)
  • Refactor _apply_global_margin_limits() to work with prices instead of percentages
    • Now calculates min/max prices based on global_margin_type
    • Applied after item's margin calculation, not before
  • Add 2 new tests for global limits with Commercial Margin type
  • Update configuration UI with global margin type selector
  • Total: 13 tests passing

18.0.1.1.0 (2026-02-21)

  • [IMP] Add margin_type field to choose between calculation methods
    • Markup (on cost): PVP = Cost × (1 + markup%)
    • Commercial Margin (on PVP): PVP = Cost / (1 - margin%)
  • Add 2 new tests for both calculation methods
  • Add safety cap for commercial margin >= 100% (caps at 99%)
  • Improve logging to show which calculation method is used
  • Total: 11 tests passing

18.0.1.0.0 (2026-02-21)

  • Initial implementation
  • Support for additive margin calculation in chained pricelists
  • Compatible with last_purchase_price custom base
  • Global min/max margin limits configuration
  • Comprehensive test suite (9 tests)
  • Detailed logging for debugging