Compare commits
3 commits
55406ca22d
...
b31df7b9d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31df7b9d8 | ||
|
|
449bb75bb6 | ||
|
|
07cc0eb517 |
6 changed files with 333 additions and 39 deletions
|
|
@ -22,17 +22,36 @@ Pricelist B: 25% markup → 4.446 × 1.25 = 5.5575€
|
||||||
|
|
||||||
With this module, you can calculate the **total margin** by summing percentages:
|
With this module, you can calculate the **total margin** by summing percentages:
|
||||||
|
|
||||||
|
#### Option 1: Markup (on cost)
|
||||||
```
|
```
|
||||||
Base price: 4.68€
|
Base price: 4.68€
|
||||||
Total margin: -5% + 25% = 20%
|
Total margin: -5% + 25% = 20%
|
||||||
Final price: 4.68 × 1.20 = 5.616€
|
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
|
## Features
|
||||||
|
|
||||||
- ✅ **Additive margin calculation** across chained pricelists
|
- ✅ **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
|
- ✅ **Opt-in via checkbox** - doesn't affect existing pricelists
|
||||||
- ✅ **Compatible with custom bases** (`last_purchase_price` from `product_sale_price_from_pricelist`)
|
- ✅ **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)
|
- ✅ **Supports all formula extras** (price_round, price_surcharge, price_min/max_margin)
|
||||||
|
|
@ -84,9 +103,29 @@ Create a pricelist that chains to the base one:
|
||||||
- **Based on:** Other Pricelist → Select "Base Pricelist - Last Purchase Price"
|
- **Based on:** Other Pricelist → Select "Base Pricelist - Last Purchase Price"
|
||||||
- **Price Computation:** Formula
|
- **Price Computation:** Formula
|
||||||
- **Discount:** -25% (negative = 25% markup)
|
- **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
|
### 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**
|
- For automatic price calculation, configure the pricelist in **Settings > Sales > Automatic Price Configuration**
|
||||||
- Or assign the pricelist to specific customers/partners
|
- Or assign the pricelist to specific customers/partners
|
||||||
|
|
@ -113,19 +152,62 @@ After product category (+25%): 5.5575€ ❌ Wrong effective margin: 18.8%
|
||||||
Product: Flour (cesta básica, repostería)
|
Product: Flour (cesta básica, repostería)
|
||||||
Purchase price: 4.68€
|
Purchase price: 4.68€
|
||||||
Total margin: -5% + 25% = 20%
|
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
|
## Technical Details
|
||||||
|
|
||||||
### New Field
|
### New Fields
|
||||||
|
|
||||||
- **Model:** `product.pricelist.item`
|
- **Model:** `product.pricelist.item`
|
||||||
- **Field:** `use_total_margin` (Boolean)
|
|
||||||
|
#### `use_total_margin` (Boolean)
|
||||||
- **Default:** False (opt-in)
|
- **Default:** False (opt-in)
|
||||||
- **Visibility:** Only shown when:
|
- **Visibility:** Only shown when:
|
||||||
- `compute_price = 'formula'`
|
- `compute_price = 'formula'`
|
||||||
- `base = 'pricelist'` (chained pricelist)
|
- `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
|
### Methods
|
||||||
|
|
||||||
|
|
@ -153,15 +235,37 @@ Applies additional formula options:
|
||||||
- `price_min_margin`: Enforce minimum margin
|
- `price_min_margin`: Enforce minimum margin
|
||||||
- `price_max_margin`: Enforce maximum 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]
|
#### `_compute_price(product, quantity, uom, date, currency)` [OVERRIDE]
|
||||||
|
|
||||||
Main override that:
|
Main override that:
|
||||||
1. Checks if `use_total_margin=True` and conditions are met
|
1. Checks if `use_total_margin=True` and conditions are met
|
||||||
2. Calls helper methods to get base price and margins
|
2. Calls helper methods to get base price and margins
|
||||||
3. Sums margins additively: `total_margin = sum(margins)`
|
3. Sums margins additively: `total_margin = sum(margins)`
|
||||||
4. Applies total margin: `price = base_price * (1 + total_margin / 100)`
|
4. Applies total margin using item's selected method:
|
||||||
5. Applies formula extras
|
- **Markup:** `price = base_price * (1 + total_margin / 100)`
|
||||||
6. Falls back to standard behavior if conditions not met
|
- **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
|
### Logging
|
||||||
|
|
||||||
|
|
@ -170,7 +274,12 @@ All calculations are logged with `[TOTAL MARGIN]` prefix for easy debugging:
|
||||||
```python
|
```python
|
||||||
_logger.info("[TOTAL MARGIN] Item %s: base=%s, margin=%.2f%%", ...)
|
_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] 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:
|
View logs:
|
||||||
|
|
@ -189,12 +298,18 @@ docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_
|
||||||
### Test Coverage
|
### Test Coverage
|
||||||
|
|
||||||
- ✅ Compound margin (default behavior preserved)
|
- ✅ 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)
|
- ✅ 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
|
- ✅ 3-level pricelist chains
|
||||||
- ✅ Different base types (last_purchase_price, list_price)
|
- ✅ Different base types (last_purchase_price, list_price)
|
||||||
- ✅ Currency conversions
|
- ✅ Currency conversions
|
||||||
|
|
||||||
|
**Total:** 13 tests, all passing
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
- **Odoo Version:** 18.0
|
- **Odoo Version:** 18.0
|
||||||
|
|
@ -271,10 +386,34 @@ AGPL-3.0 or later
|
||||||
|
|
||||||
## Changelog
|
## 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)
|
### 18.0.1.0.0 (2026-02-21)
|
||||||
|
|
||||||
- Initial implementation
|
- Initial implementation
|
||||||
- Support for additive margin calculation in chained pricelists
|
- Support for additive margin calculation in chained pricelists
|
||||||
- Compatible with `last_purchase_price` custom base
|
- 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
|
- Detailed logging for debugging
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
{ # noqa: B018
|
{ # noqa: B018
|
||||||
"name": "Product Pricelist Total Margin",
|
"name": "Product Pricelist Total Margin",
|
||||||
"version": "18.0.1.0.0",
|
"version": "18.0.1.2.0",
|
||||||
"category": "Sales/Products",
|
"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, enforce global limits",
|
||||||
"author": "Odoo Community Association (OCA), Criptomart",
|
"author": "Odoo Community Association (OCA), Criptomart",
|
||||||
"website": "https://git.criptomart.net/criptomart/addons-cm",
|
"website": "https://git.criptomart.net/criptomart/addons-cm",
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
|
|
|
||||||
|
|
@ -274,16 +274,21 @@ class ProductPricelistItem(models.Model):
|
||||||
|
|
||||||
return price
|
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.
|
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:
|
Args:
|
||||||
total_margin: The calculated total margin percentage
|
price: The calculated price
|
||||||
base_price: The base price (for logging)
|
base_price: The base price (cost)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: The adjusted margin percentage
|
float: The adjusted price
|
||||||
"""
|
"""
|
||||||
# Get global margin limits from configuration
|
# Get global margin limits from configuration
|
||||||
IrConfigParam = self.env["ir.config_parameter"].sudo()
|
IrConfigParam = self.env["ir.config_parameter"].sudo()
|
||||||
|
|
@ -297,32 +302,64 @@ class ProductPricelistItem(models.Model):
|
||||||
"product_pricelist_total_margin.max_percent", default="0.0"
|
"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)
|
# Calculate min/max prices based on global margin type
|
||||||
if min_margin > 0.0 and total_margin < min_margin:
|
if global_margin_type == "margin":
|
||||||
total_margin = min_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(
|
_logger.info(
|
||||||
"[TOTAL MARGIN] Applied global minimum margin: %.2f%% -> %.2f%% "
|
"[TOTAL MARGIN] Applied global minimum (%s %.2f%%): %.2f -> %.2f",
|
||||||
"(configured min: %.2f%%)",
|
global_margin_type,
|
||||||
original_margin,
|
|
||||||
total_margin,
|
|
||||||
min_margin,
|
min_margin,
|
||||||
|
original_price,
|
||||||
|
price,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply maximum margin if configured (> 0)
|
# Apply maximum price limit
|
||||||
if max_margin > 0.0 and total_margin > max_margin:
|
if max_margin > 0.0 and price > max_price:
|
||||||
total_margin = max_margin
|
price = max_price
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"[TOTAL MARGIN] Applied global maximum margin: %.2f%% -> %.2f%% "
|
"[TOTAL MARGIN] Applied global maximum (%s %.2f%%): %.2f -> %.2f",
|
||||||
"(configured max: %.2f%%)",
|
global_margin_type,
|
||||||
original_margin,
|
|
||||||
total_margin,
|
|
||||||
max_margin,
|
max_margin,
|
||||||
|
original_price,
|
||||||
|
price,
|
||||||
)
|
)
|
||||||
|
|
||||||
return total_margin
|
return price
|
||||||
|
|
||||||
def _compute_price(
|
def _compute_price(
|
||||||
self,
|
self,
|
||||||
|
|
@ -378,9 +415,6 @@ class ProductPricelistItem(models.Model):
|
||||||
total_margin,
|
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
|
# Apply total margin to base price using selected margin type
|
||||||
margin_type = self.margin_type or "markup"
|
margin_type = self.margin_type or "markup"
|
||||||
if margin_type == "margin":
|
if margin_type == "margin":
|
||||||
|
|
@ -408,6 +442,9 @@ class ProductPricelistItem(models.Model):
|
||||||
price,
|
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)
|
# Apply formula extras (round, surcharge, min/max margins)
|
||||||
price = self._apply_formula_extras(price, base_price, currency)
|
price = self._apply_formula_extras(price, base_price, currency)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class ResConfigSettings(models.TransientModel):
|
||||||
"Total Margin Mode. If the calculated margin is below this value, "
|
"Total Margin Mode. If the calculated margin is below this value, "
|
||||||
"the price will be adjusted to meet the minimum.\n\n"
|
"the price will be adjusted to meet the minimum.\n\n"
|
||||||
"Example: If set to 10%, a product with base price 100€ will have "
|
"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.",
|
"Set to 0 to disable minimum margin control.",
|
||||||
config_parameter="product_pricelist_total_margin.min_percent",
|
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, "
|
"Total Margin Mode. If the calculated margin exceeds this value, "
|
||||||
"the price will be adjusted to meet the maximum.\n\n"
|
"the price will be adjusted to meet the maximum.\n\n"
|
||||||
"Example: If set to 50%, a product with base price 100€ will have "
|
"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.",
|
"Set to 0 to disable maximum margin control.",
|
||||||
config_parameter="product_pricelist_total_margin.max_percent",
|
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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -411,3 +411,97 @@ class TestTotalMargin(TransactionCase):
|
||||||
places=4,
|
places=4,
|
||||||
msg=f"Commercial margin should be 20%, got {commercial_margin * 100}%",
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@
|
||||||
/>
|
/>
|
||||||
<field name="total_margin_max_percent" class="oe_inline" />
|
<field name="total_margin_max_percent" class="oe_inline" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label
|
||||||
|
string="Calculation Method"
|
||||||
|
for="global_margin_type"
|
||||||
|
class="col-lg-3 o_light_label"
|
||||||
|
/>
|
||||||
|
<field name="global_margin_type" class="oe_inline" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue