diff --git a/product_pricelist_total_margin/README.md b/product_pricelist_total_margin/README.md
index c8e1052..bf220a9 100644
--- a/product_pricelist_total_margin/README.md
+++ b/product_pricelist_total_margin/README.md
@@ -22,36 +22,17 @@ Pricelist B: 25% markup → 4.446 × 1.25 = 5.5575€
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)
+**Result:** 5.62€ (effective margin: 20%)
## 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)
@@ -103,29 +84,9 @@ Create a pricelist that chains to the base one:
- **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%)`
+ - **☑️ Use Total Margin:** Check this box!
-### 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
+### Step 3: Assign to Products
- For automatic price calculation, configure the pricelist in **Settings > Sales > Automatic Price Configuration**
- Or assign the pricelist to specific customers/partners
@@ -152,62 +113,19 @@ After product category (+25%): 5.5575€ ❌ Wrong effective margin: 18.8%
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
+Final price: 5.616€ ✅ Correct effective margin: 20%
```
-**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
+### New Field
- **Model:** `product.pricelist.item`
-
-#### `use_total_margin` (Boolean)
+- **Field:** `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
@@ -235,37 +153,15 @@ Applies additional formula options:
- `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
+4. Applies total margin: `price = base_price * (1 + total_margin / 100)`
+5. Applies formula extras
+6. Falls back to standard behavior if conditions not met
### Logging
@@ -274,12 +170,7 @@ All calculations are logged with `[TOTAL MARGIN]` prefix for easy debugging:
```python
_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")
+_logger.info("[TOTAL MARGIN] Base price 4.68 * (1 + 20.0%) = 5.616")
```
View logs:
@@ -298,18 +189,12 @@ docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_
### Test Coverage
- ✅ Compound margin (default behavior preserved)
-- ✅ Total margin with Markup calculation
-- ✅ Total margin with Commercial Margin calculation
+- ✅ Total margin (additive 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
@@ -386,34 +271,10 @@ AGPL-3.0 or later
## 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)
+- Comprehensive test suite
- Detailed logging for debugging
diff --git a/product_pricelist_total_margin/__manifest__.py b/product_pricelist_total_margin/__manifest__.py
index b940e05..4f714db 100644
--- a/product_pricelist_total_margin/__manifest__.py
+++ b/product_pricelist_total_margin/__manifest__.py
@@ -2,9 +2,9 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Product Pricelist Total Margin",
- "version": "18.0.1.2.0",
+ "version": "18.0.1.0.0",
"category": "Sales/Products",
- "summary": "Calculate total margin additively with Markup or Commercial Margin methods, enforce global limits",
+ "summary": "Calculate total margin additively instead of compounding in chained pricelists",
"author": "Odoo Community Association (OCA), Criptomart",
"website": "https://git.criptomart.net/criptomart/addons-cm",
"license": "AGPL-3",
diff --git a/product_pricelist_total_margin/models/product_pricelist_item.py b/product_pricelist_total_margin/models/product_pricelist_item.py
index a0f90af..9b1981f 100644
--- a/product_pricelist_total_margin/models/product_pricelist_item.py
+++ b/product_pricelist_total_margin/models/product_pricelist_item.py
@@ -274,21 +274,16 @@ class ProductPricelistItem(models.Model):
return price
- def _apply_global_margin_limits(self, price, base_price):
+ def _apply_global_margin_limits(self, total_margin, 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:
- price: The calculated price
- base_price: The base price (cost)
+ total_margin: The calculated total margin percentage
+ base_price: The base price (for logging)
Returns:
- float: The adjusted price
+ float: The adjusted margin percentage
"""
# Get global margin limits from configuration
IrConfigParam = self.env["ir.config_parameter"].sudo()
@@ -302,64 +297,32 @@ 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"
- )
- if min_margin <= 0.0 and max_margin <= 0.0:
- # No limits configured
- return price
+ original_margin = total_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
+ # 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 (%s %.2f%%): %.2f -> %.2f",
- global_margin_type,
+ "[TOTAL MARGIN] Applied global minimum margin: %.2f%% -> %.2f%% "
+ "(configured min: %.2f%%)",
+ original_margin,
+ total_margin,
min_margin,
- original_price,
- price,
)
- # Apply maximum price limit
- if max_margin > 0.0 and price > max_price:
- price = max_price
+ # 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 (%s %.2f%%): %.2f -> %.2f",
- global_margin_type,
+ "[TOTAL MARGIN] Applied global maximum margin: %.2f%% -> %.2f%% "
+ "(configured max: %.2f%%)",
+ original_margin,
+ total_margin,
max_margin,
- original_price,
- price,
)
- return price
+ return total_margin
def _compute_price(
self,
@@ -415,6 +378,9 @@ 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":
@@ -442,9 +408,6 @@ 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)
diff --git a/product_pricelist_total_margin/models/res_config_settings.py b/product_pricelist_total_margin/models/res_config_settings.py
index 8e92050..76d720a 100644
--- a/product_pricelist_total_margin/models/res_config_settings.py
+++ b/product_pricelist_total_margin/models/res_config_settings.py
@@ -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€ (markup) or 111.11€ (commercial margin).\n\n"
+ "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",
)
@@ -27,23 +27,7 @@ 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€ (markup) or 200€ (commercial margin).\n\n"
+ "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",
)
-
- 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",
- )
diff --git a/product_pricelist_total_margin/tests/test_total_margin.py b/product_pricelist_total_margin/tests/test_total_margin.py
index 6ace425..2929015 100644
--- a/product_pricelist_total_margin/tests/test_total_margin.py
+++ b/product_pricelist_total_margin/tests/test_total_margin.py
@@ -411,97 +411,3 @@ 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"
- )
diff --git a/product_pricelist_total_margin/views/res_config_settings_views.xml b/product_pricelist_total_margin/views/res_config_settings_views.xml
index 677ae33..2139c57 100644
--- a/product_pricelist_total_margin/views/res_config_settings_views.xml
+++ b/product_pricelist_total_margin/views/res_config_settings_views.xml
@@ -25,14 +25,6 @@
/>