From 07cc0eb51771e4338e1d5127a496bc4c4efeb0a0 Mon Sep 17 00:00:00 2001 From: snt Date: Sat, 21 Feb 2026 19:11:01 +0100 Subject: [PATCH 1/3] [DOC] product_pricelist_total_margin: Update documentation for v1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update README with margin_type field documentation - Add detailed explanations for both calculation methods: * Markup (on cost): PVP = Cost × (1 + markup%) * Commercial Margin (on PVP): PVP = Cost / (1 - margin%) - Add examples comparing both methods - Update features list with new capabilities - Update configuration steps to include margin_type selector - Update technical details with new field specifications - Update test coverage section (11 tests) - Add logging examples for both methods - Update changelog with v18.0.1.1.0 release notes - Bump version to 18.0.1.1.0 in __manifest__.py - Update summary to reflect new features --- product_pricelist_total_margin/README.md | 85 ++++++++++++++++--- .../__manifest__.py | 4 +- 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/product_pricelist_total_margin/README.md b/product_pricelist_total_margin/README.md index bf220a9..79a47e4 100644 --- a/product_pricelist_total_margin/README.md +++ b/product_pricelist_total_margin/README.md @@ -22,20 +22,36 @@ 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€ (effective margin: 20%) +**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:** + - **Markup (on cost):** `PVP = Cost × (1 + markup%)` + - **Commercial Margin (on PVP):** `PVP = Cost / (1 - margin%)` - ✅ **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) +- ✅ **Global min/max margin limits** (configurable in Settings > Sales) - ✅ **Multi-level chains** - works with 2+ pricelists in sequence - ✅ **Currency conversion** - handles multi-currency scenarios - ✅ **Detailed logging** - debug pricing calculations easily @@ -84,7 +100,10 @@ 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) - - **☑️ Use Total Margin:** Check this box! + - **☑️ 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: Assign to Products @@ -113,19 +132,38 @@ 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% -Final price: 5.616€ ✅ Correct effective margin: 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 Field +### New Fields - **Model:** `product.pricelist.item` -- **Field:** `use_total_margin` (Boolean) + +#### `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 ### Methods @@ -159,9 +197,14 @@ 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: `price = base_price * (1 + total_margin / 100)` -5. Applies formula extras -6. Falls back to standard behavior if conditions not met +4. Applies global min/max margin limits if configured +5. Applies total margin using selected method: + - **Markup:** `price = base_price * (1 + total_margin / 100)` + - **Commercial Margin:** `price = base_price / (1 - total_margin / 100)` +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 @@ -170,7 +213,12 @@ 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") -_logger.info("[TOTAL MARGIN] Base price 4.68 * (1 + 20.0%) = 5.616") + +# 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: @@ -189,12 +237,16 @@ docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_ ### Test Coverage - ✅ Compound margin (default behavior preserved) -- ✅ Total margin (additive calculation) +- ✅ Total margin with Markup calculation +- ✅ Total margin with Commercial Margin calculation - ✅ Formula extras (round, surcharge, min/max) +- ✅ Global min/max margin limits - ✅ 3-level pricelist chains - ✅ Different base types (last_purchase_price, list_price) - ✅ Currency conversions +**Total:** 11 tests, all passing + ## Compatibility - **Odoo Version:** 18.0 @@ -271,10 +323,21 @@ AGPL-3.0 or later ## Changelog +### 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 -- Comprehensive test suite +- Global min/max margin limits configuration +- Comprehensive test suite (9 tests) - Detailed logging for debugging diff --git a/product_pricelist_total_margin/__manifest__.py b/product_pricelist_total_margin/__manifest__.py index 4f714db..8003703 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.0.0", + "version": "18.0.1.1.0", "category": "Sales/Products", - "summary": "Calculate total margin additively instead of compounding in chained pricelists", + "summary": "Calculate total margin additively with Markup or Commercial Margin methods", "author": "Odoo Community Association (OCA), Criptomart", "website": "https://git.criptomart.net/criptomart/addons-cm", "license": "AGPL-3", From 449bb75bb6ac96bc2379e0f2028746a24d171871 Mon Sep 17 00:00:00 2001 From: snt Date: Sat, 21 Feb 2026 19:16:16 +0100 Subject: [PATCH 2/3] [IMP] product_pricelist_total_margin: Add global_margin_type for min/max limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add global_margin_type field in res.config.settings * Options: 'markup' (default) or 'margin' (commercial margin) * Determines how global min/max percentages are interpreted - Refactor _apply_global_margin_limits(): * Now receives price instead of margin percentage * Calculates min/max prices based on global_margin_type * Returns adjusted price instead of adjusted margin * Supports both markup and commercial margin formulas - Update _compute_price() to apply limits after price calculation - Update res_config_settings_views.xml to show global_margin_type selector - Update help texts with examples for both calculation methods - Add 2 new tests to validate global limits with commercial margin type: * test_global_minimum_with_commercial_margin_type * test_global_maximum_with_commercial_margin_type - All 13 tests passing (11 existing + 2 new) Example with global min 25%: - Markup: Min price = Cost × 1.25 - Commercial Margin: Min price = Cost / 0.75 (ensures 25% margin on PVP) --- .../models/product_pricelist_item.py | 83 +++++++++++----- .../models/res_config_settings.py | 20 +++- .../tests/test_total_margin.py | 94 +++++++++++++++++++ .../views/res_config_settings_views.xml | 8 ++ 4 files changed, 180 insertions(+), 25 deletions(-) diff --git a/product_pricelist_total_margin/models/product_pricelist_item.py b/product_pricelist_total_margin/models/product_pricelist_item.py index 9b1981f..a0f90af 100644 --- a/product_pricelist_total_margin/models/product_pricelist_item.py +++ b/product_pricelist_total_margin/models/product_pricelist_item.py @@ -274,16 +274,21 @@ class ProductPricelistItem(models.Model): return price - def _apply_global_margin_limits(self, total_margin, base_price): + def _apply_global_margin_limits(self, price, 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: - total_margin: The calculated total margin percentage - base_price: The base price (for logging) + price: The calculated price + base_price: The base price (cost) Returns: - float: The adjusted margin percentage + float: The adjusted price """ # Get global margin limits from configuration IrConfigParam = self.env["ir.config_parameter"].sudo() @@ -297,32 +302,64 @@ 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" + ) - original_margin = total_margin + if min_margin <= 0.0 and max_margin <= 0.0: + # No limits configured + return price - # Apply minimum margin if configured (> 0) - if min_margin > 0.0 and total_margin < min_margin: - total_margin = min_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 _logger.info( - "[TOTAL MARGIN] Applied global minimum margin: %.2f%% -> %.2f%% " - "(configured min: %.2f%%)", - original_margin, - total_margin, + "[TOTAL MARGIN] Applied global minimum (%s %.2f%%): %.2f -> %.2f", + global_margin_type, min_margin, + original_price, + price, ) - # Apply maximum margin if configured (> 0) - if max_margin > 0.0 and total_margin > max_margin: - total_margin = max_margin + # Apply maximum price limit + if max_margin > 0.0 and price > max_price: + price = max_price _logger.info( - "[TOTAL MARGIN] Applied global maximum margin: %.2f%% -> %.2f%% " - "(configured max: %.2f%%)", - original_margin, - total_margin, + "[TOTAL MARGIN] Applied global maximum (%s %.2f%%): %.2f -> %.2f", + global_margin_type, max_margin, + original_price, + price, ) - return total_margin + return price def _compute_price( self, @@ -378,9 +415,6 @@ 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": @@ -408,6 +442,9 @@ 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 76d720a..8e92050 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€, regardless of calculated margins.\n\n" + "a minimum final price of 110€ (markup) or 111.11€ (commercial margin).\n\n" "Set to 0 to disable minimum margin control.", config_parameter="product_pricelist_total_margin.min_percent", ) @@ -27,7 +27,23 @@ 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€, regardless of calculated margins.\n\n" + "a maximum final price of 150€ (markup) or 200€ (commercial margin).\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 2929015..6ace425 100644 --- a/product_pricelist_total_margin/tests/test_total_margin.py +++ b/product_pricelist_total_margin/tests/test_total_margin.py @@ -411,3 +411,97 @@ 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 2139c57..677ae33 100644 --- a/product_pricelist_total_margin/views/res_config_settings_views.xml +++ b/product_pricelist_total_margin/views/res_config_settings_views.xml @@ -25,6 +25,14 @@ /> +
+
From b31df7b9d8318912631557eff31f0e0bded99d64 Mon Sep 17 00:00:00 2001 From: snt Date: Sat, 21 Feb 2026 19:19:25 +0100 Subject: [PATCH 3/3] [DOC] product_pricelist_total_margin: Update docs and version to 18.0.1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- product_pricelist_total_margin/README.md | 90 +++++++++++++++++-- .../__manifest__.py | 4 +- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/product_pricelist_total_margin/README.md b/product_pricelist_total_margin/README.md index 79a47e4..c8e1052 100644 --- a/product_pricelist_total_margin/README.md +++ b/product_pricelist_total_margin/README.md @@ -45,13 +45,16 @@ Effective margin: (5.85 - 4.68) / 5.85 = 20% ## Features - ✅ **Additive margin calculation** across chained pricelists -- ✅ **Two calculation methods:** +- ✅ **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) -- ✅ **Global min/max margin limits** (configurable in Settings > Sales) - ✅ **Multi-level chains** - works with 2+ pricelists in sequence - ✅ **Currency conversion** - handles multi-currency scenarios - ✅ **Detailed logging** - debug pricing calculations easily @@ -105,7 +108,24 @@ Create a pricelist that chains to the base one: - **Markup (on cost)** - Default, calculates `PVP = Cost × (1 + markup%)` - **Commercial Margin (on PVP)** - Calculates `PVP = Cost / (1 - margin%)` -### Step 3: Assign to Products +### 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 @@ -165,6 +185,30 @@ Option 2 - Commercial 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)` @@ -191,16 +235,33 @@ 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 global min/max margin limits if configured -5. Applies total margin using selected method: +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 @@ -240,12 +301,14 @@ docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_ - ✅ Total margin with Markup calculation - ✅ Total margin with Commercial Margin calculation - ✅ Formula extras (round, surcharge, min/max) -- ✅ Global min/max margin limits +- ✅ 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:** 11 tests, all passing +**Total:** 13 tests, all passing ## Compatibility @@ -323,6 +386,19 @@ 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 diff --git a/product_pricelist_total_margin/__manifest__.py b/product_pricelist_total_margin/__manifest__.py index 8003703..b940e05 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.1.0", + "version": "18.0.1.2.0", "category": "Sales/Products", - "summary": "Calculate total margin additively with Markup or Commercial Margin methods", + "summary": "Calculate total margin additively with Markup or Commercial Margin methods, enforce global limits", "author": "Odoo Community Association (OCA), Criptomart", "website": "https://git.criptomart.net/criptomart/addons-cm", "license": "AGPL-3",