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 @@ /> -
-