[ADD] product_pricelist_total_margin: New module for additive margin calculation

- Add use_total_margin field to pricelist items
- Override _compute_price() to sum margins additively instead of compounding
- Support chained pricelists with custom base types (last_purchase_price)
- Add global minimum and maximum margin limits configuration
- Store limits in ir.config_parameter via res.config.settings
- Apply global limits after total margin calculation
- Add comprehensive test suite (9 tests) covering:
  * Basic additive vs compound margin behavior
  * Three-level pricelist chains
  * Global minimum/maximum margin enforcement
  * Rounding and surcharge compatibility
- Add configuration UI in Settings > Sales
- All tests passing (9/9)

This module fixes the issue where chained pricelists were compounding margins
instead of calculating total margins. Example: base 4.68€ with -5% and +25%
now correctly results in 5.616€ (20% total) instead of 5.56€ (compound).
This commit is contained in:
snt 2026-02-21 16:11:13 +01:00
parent f35bf0c5a1
commit 32f345bc44
12 changed files with 1143 additions and 0 deletions

View file

@ -0,0 +1,280 @@
# Product Pricelist Total Margin
## Overview
This module solves the problem of **compounded margins** when using chained pricelists in Odoo. By default, Odoo applies each pricelist rule's margin on top of the previous result, leading to compounded percentages. This module provides an option to calculate margins **additively** instead.
## Problem Description
### Standard Odoo Behavior (Compound Margins)
When you chain pricelists (Pricelist A → Pricelist B), Odoo applies margins in cascade:
```
Base price: 4.68€
Pricelist A: -5% discount → 4.68 × 0.95 = 4.446€
Pricelist B: 25% markup → 4.446 × 1.25 = 5.5575€
```
**Result:** 5.56€ (effective margin: 18.8%)
### Desired Behavior (Total/Additive Margins)
With this module, you can calculate the **total margin** by summing percentages:
```
Base price: 4.68€
Total margin: -5% + 25% = 20%
Final price: 4.68 × 1.20 = 5.616€
```
**Result:** 5.62€ (effective margin: 20%)
## Features
- ✅ **Additive margin calculation** across chained pricelists
- ✅ **Opt-in via checkbox** - doesn't affect existing pricelists
- ✅ **Compatible with custom bases** (`last_purchase_price` from `product_sale_price_from_pricelist`)
- ✅ **Supports all formula extras** (price_round, price_surcharge, price_min/max_margin)
- ✅ **Multi-level chains** - works with 2+ pricelists in sequence
- ✅ **Currency conversion** - handles multi-currency scenarios
- ✅ **Detailed logging** - debug pricing calculations easily
## Installation
1. **Install dependencies:**
```bash
# Ensure these modules are installed:
- product
- product_price_category
- product_sale_price_from_pricelist
```
2. **Install module:**
```bash
docker-compose exec odoo odoo -d odoo -u product_pricelist_total_margin --stop-after-init
```
3. **Restart Odoo:**
```bash
docker-compose restart odoo
```
## Configuration
### Step 1: Create Base Pricelist
Create a pricelist that defines your base pricing logic:
1. Go to **Sales > Configuration > Pricelists**
2. Create a new pricelist: "Base Pricelist - Last Purchase Price"
3. Add a rule:
- **Apply On:** All Products
- **Based on:** Last Purchase Price (or List Price, Standard Price, etc.)
- **Price Computation:** Formula
- **Discount:** 5% (for "cesta básica" category example)
### Step 2: Create Chained Pricelist
Create a pricelist that chains to the base one:
1. Create a new pricelist: "Category Margin - Repostería"
2. Add a rule:
- **Apply On:** All Products (or specific category)
- **Based on:** Other Pricelist → Select "Base Pricelist - Last Purchase Price"
- **Price Computation:** Formula
- **Discount:** -25% (negative = 25% markup)
- **☑️ Use Total Margin:** Check this box!
### 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
## Usage Example
### Scenario: Cooperative Pricing System
Your cooperative has two margin rules:
1. **Price Category Discount:** "Cesta Básica" products get -5% (to make them affordable)
2. **Product Category Markup:** "Repostería" products get +25% (higher margin category)
**Without this module (compound):**
```
Product: Flour (cesta básica, repostería)
Purchase price: 4.68€
After price category (-5%): 4.446€
After product category (+25%): 5.5575€ ❌ Wrong effective margin: 18.8%
```
**With this module (total margin enabled):**
```
Product: Flour (cesta básica, repostería)
Purchase price: 4.68€
Total margin: -5% + 25% = 20%
Final price: 5.616€ ✅ Correct effective margin: 20%
```
## Technical Details
### New Field
- **Model:** `product.pricelist.item`
- **Field:** `use_total_margin` (Boolean)
- **Default:** False (opt-in)
- **Visibility:** Only shown when:
- `compute_price = 'formula'`
- `base = 'pricelist'` (chained pricelist)
### Methods
#### `_get_base_price_and_margins(product, quantity, uom, date, currency)`
Traverses the pricelist chain backwards to:
1. Find the original base price (before any margins)
2. Collect all margin percentages along the chain
Returns: `(base_price: float, margins: list[float])`
#### `_compute_original_base_price(item, product, quantity, uom, date, currency)`
Computes the base price from the bottom item of the chain, supporting:
- `last_purchase_price` (custom from `product_sale_price_from_pricelist`)
- `list_price` (standard)
- `standard_price` (cost)
- Currency conversions
#### `_apply_formula_extras(price, base_price, currency)`
Applies additional formula options:
- `price_round`: Round to nearest value
- `price_surcharge`: Add fixed amount
- `price_min_margin`: Enforce minimum margin
- `price_max_margin`: Enforce maximum margin
#### `_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: `price = base_price * (1 + total_margin / 100)`
5. Applies formula extras
6. Falls back to standard behavior if conditions not met
### Logging
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")
```
View logs:
```bash
docker-compose logs -f odoo | grep "TOTAL MARGIN"
```
## Testing
### Run Tests
```bash
docker-compose run odoo odoo -d odoo --test-enable --stop-after-init -u product_pricelist_total_margin
```
### Test Coverage
- ✅ Compound margin (default behavior preserved)
- ✅ Total margin (additive calculation)
- ✅ Formula extras (round, surcharge, min/max)
- ✅ 3-level pricelist chains
- ✅ Different base types (last_purchase_price, list_price)
- ✅ Currency conversions
## Compatibility
- **Odoo Version:** 18.0
- **Python Version:** 3.10+
- **Dependencies:**
- `product` (core)
- `product_price_category` (OCA)
- `product_sale_price_from_pricelist` (custom)
## Limitations
1. **Only works with `compute_price='formula'`**: Fixed prices and percentage-based rules are not affected
2. **Circular references**: The module detects and breaks circular pricelist chains, but logs a warning
3. **Performance**: Traversing long pricelist chains may impact performance (though minimal in practice)
## Troubleshooting
### Margins still compound even with checkbox enabled
**Check:**
1. Is `compute_price` set to "Formula"?
2. Is `base` set to "Other Pricelist"?
3. Is the checkbox actually checked and saved?
4. Check logs for `[TOTAL MARGIN]` entries to see if logic is being triggered
### Price is incorrect
**Debug:**
1. Enable developer mode
2. Check logs: `docker-compose logs -f odoo | grep "TOTAL MARGIN"`
3. Verify:
- Base price is correct
- All margins are collected
- Currency conversions are applied
- Formula extras (round, surcharge) are expected
### Checkbox not visible
**Possible causes:**
- `compute_price` is not "Formula" (must be formula-based)
- `base` is not "Other Pricelist" (no chain to traverse)
- View not properly loaded (try reloading page or clearing browser cache)
## Development
### File Structure
```
product_pricelist_total_margin/
├── __init__.py
├── __manifest__.py
├── README.md
├── models/
│ ├── __init__.py
│ └── product_pricelist_item.py
├── tests/
│ ├── __init__.py
│ └── test_total_margin.py
└── views/
└── product_pricelist_item_views.xml
```
### Contributing
Follow OCA guidelines and project conventions defined in `.github/copilot-instructions.md`.
## License
AGPL-3.0 or later
## Author
**Kidekoop** - 2026
## Changelog
### 18.0.1.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
- Detailed logging for debugging