Pricing matrix

This commit is contained in:
2026-05-25 09:04:24 +02:00
parent 1e5611f726
commit ddca542bfc
7 changed files with 315 additions and 57 deletions

View File

@@ -33,7 +33,7 @@ des lignes achat / vente.
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Matrix</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">La matrix existe dans le modele mais n&#x27;est pas encore le moteur principal du fixing automatique.</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Une matrix peut alimenter un composant de fixing, avec ligne generique ou conditions.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Pricing period</td>
@@ -228,6 +228,14 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
<td style="vertical-align:top; padding:0.5rem 0.7rem;">L&#x27;utilisateur choisit une courbe, un calendrier et active <code>Auto</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Source curve</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Le systeme lit les prix depuis <code>price_index</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Source matrix</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Le systeme lit les prix depuis <code>price_matrix.line</code>.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Trigger</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">L&#x27;utilisateur definit une periode de prix et une periode d&#x27;application.</td>
</tr>
@@ -255,6 +263,10 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Cut-off</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>Pricing date max</code> arrete la generation apres cette date.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Ratio negatif</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Un ratio <code>-100%</code> soustrait le composant du prix basis.</td>
</tr>
</tbody>
</table>
@@ -307,6 +319,46 @@ Summary: derniere ligne marquee last</code></pre>
<div style="border-left:0.28rem solid #4051b5; background:#f5f7ff; padding:0.85rem 1rem; margin:1rem 0 1.4rem 0; border-radius:0.35rem;"><strong style="display:block; margin-bottom:0.35rem;">Exemple simple</strong><span>Pricing <code>01/04 -&gt; 05/04</code>, Application vide. Le systeme lit les prix du 1 au 5 avril et cree les applications sur ces memes dates, sauf jours exclus par le calendrier si un calendrier est renseigne.</span></div>
### BR-PT-PRI-006 - Matrix de premium ou spread
<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">
<thead>
<tr style="background:#eef3ff;">
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Cas</th>
<th style="text-align:left; padding:0.55rem 0.7rem; border-bottom:2px solid #4051b5;">Regle</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Matrix generique</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Une ligne matrix sans origin, destination, product ni quality s&#x27;applique comme fallback global.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Matrix conditionnelle</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Une ligne avec origin, destination, product ou quality est prioritaire si elle correspond a la ligne trade.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Prix</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>price_value</code> est converti vers l&#x27;unite / devise de la ligne si la matrix les renseigne.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Validite</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>valid_from</code> et <code>valid_to</code> limitent la periode d&#x27;utilisation de la matrix.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Calendrier</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Si le composant n&#x27;a pas de calendrier, le calendrier de la matrix est utilise.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Soustraction</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Pour soustraire un premium, mettre le ratio du composant a <code>-100%</code>.</td>
</tr>
</tbody>
</table>
<div style="border-left:0.28rem solid #4051b5; background:#f5f7ff; padding:0.85rem 1rem; margin:1rem 0 1.4rem 0; border-radius:0.35rem;"><strong style="display:block; margin-bottom:0.35rem;">Usage premium simple</strong><span>Creer une matrix avec une ligne sans condition et <code>price_value = 45</code>, puis ajouter un composant de type <code>Matrix</code> sur la ligne trade. Avec un ratio <code>-100%</code>, ce composant diminue le prix basis de 45 dans le repere de la ligne.</span></div>
## Section developpeur
### Modeles pivots
@@ -389,7 +441,7 @@ Summary: derniere ligne marquee last</code></pre>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Matrix</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Disponible dans le modele et le MTM, pas encore completement integree au fixing auto.</td>
<td style="vertical-align:top; padding:0.5rem 0.7rem;">Disponible dans le fixing auto, avec fallback generique et priorite aux lignes conditionnelles.</td>
</tr>
<tr style="border-bottom:1px solid #e0e0e0;">
<td style="vertical-align:top; padding:0.5rem 0.7rem;"><code>prDate</code> / <code>apPrice</code></td>

View File

@@ -20,7 +20,7 @@ des lignes achat / vente.
| --- | --- |
| Composant | Un `pricing.component` appartient a une ligne achat ou vente. |
| Courbe | La courbe principale est `price_index`. |
| Matrix | La matrix existe dans le modele mais n'est pas encore le moteur principal du fixing automatique. |
| Matrix | Une matrix peut alimenter un composant de fixing, avec ligne generique ou conditions. |
| Pricing period | Definit les dates ou lire les prix sur la courbe. |
| Application period | Definit les dates ou appliquer les tranches de fixing. |
| AVG | Utilise la moyenne progressive des prix lus. |
@@ -105,6 +105,8 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
| Etape | Effet metier |
| --- | --- |
| Composant | L'utilisateur choisit une courbe, un calendrier et active `Auto`. |
| Source curve | Le systeme lit les prix depuis `price_index`. |
| Source matrix | Le systeme lit les prix depuis `price_matrix.line`. |
| Trigger | L'utilisateur definit une periode de prix et une periode d'application. |
| Lecture prix | Le systeme lit les prix de la courbe sur les dates de pricing. |
| Application | Le systeme cree une ligne de pricing par date d'application. |
@@ -112,6 +114,7 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
| Moyenne | Si `AVG` est coche, le prix fixe utilise la moyenne progressive. |
| Fallback prix | Si `LAST` est coche, la courbe peut reprendre le dernier prix disponible. |
| Cut-off | `Pricing date max` arrete la generation apres cette date. |
| Ratio negatif | Un ratio `-100%` soustrait le composant du prix basis. |
```text
Prix lus: P1 P2 P3 P4
@@ -137,6 +140,22 @@ Summary: derniere ligne marquee last
> 5 avril et cree les applications sur ces memes dates, sauf jours exclus par le
> calendrier si un calendrier est renseigne.
### BR-PT-PRI-006 - Matrix de premium ou spread
| Cas | Regle |
| --- | --- |
| Matrix generique | Une ligne matrix sans origin, destination, product ni quality s'applique comme fallback global. |
| Matrix conditionnelle | Une ligne avec origin, destination, product ou quality est prioritaire si elle correspond a la ligne trade. |
| Prix | `price_value` est converti vers l'unite / devise de la ligne si la matrix les renseigne. |
| Validite | `valid_from` et `valid_to` limitent la periode d'utilisation de la matrix. |
| Calendrier | Si le composant n'a pas de calendrier, le calendrier de la matrix est utilise. |
| Soustraction | Pour soustraire un premium, mettre le ratio du composant a `-100%`. |
> **Usage premium simple**
> Creer une matrix avec une ligne sans condition et `price_value = 45`, puis
> ajouter un composant de type `Matrix` sur la ligne trade. Avec un ratio
> `-100%`, ce composant diminue le prix basis de 45 dans le repere de la ligne.
## Section developpeur
### Modeles pivots
@@ -175,7 +194,7 @@ Summary: derniere ligne marquee last
| `fallback` composant | Champ present, logique metier encore portee par `trigger.last`. |
| `last` trigger | Signifie dernier prix disponible sur la curve. |
| `last` pricing | Signifie derniere ligne du groupe pour le summary. |
| Matrix | Disponible dans le modele et le MTM, pas encore completement integree au fixing auto. |
| Matrix | Disponible dans le fixing auto, avec fallback generique et priorite aux lignes conditionnelles. |
| `prDate` / `apPrice` | Variables intermediaires historiques peu exploitees. |
| AVG | Moyenne progressive calculee sur la liste des prix lus. |

View File

@@ -709,25 +709,121 @@ class Component(ModelSQL, ModelView):
triggers = fields.One2Many('pricing.trigger','component',"Period rules")
pricing_date = fields.Date("Pricing date max")
def get_rec_name(self, name=None):
if self.price_index:
return '[' + self.fix_type.name + '] ' + self.price_index.price_index
else:
return '[' + self.fix_type.name + '] '
def get_cur(self,name):
if self.price_index:
PI = Pool().get('price.price')
pi = PI(self.price_index)
return pi.price_currency
def get_nbdays(self, name):
days = 0
if self.triggers:
for t in self.triggers:
l,l2 = t.getApplicationListDates(self.calendar)
days += len(l)
return days
def get_rec_name(self, name=None):
if self.price_index:
return '[' + self.fix_type.name + '] ' + self.price_index.price_index
if self.price_matrix:
return '[' + self.fix_type.name + '] ' + self.price_matrix.name
else:
return '[' + self.fix_type.name + '] '
def get_cur(self,name):
if self.price_index:
PI = Pool().get('price.price')
pi = PI(self.price_index)
return pi.price_currency
if self.price_matrix:
return self.price_matrix.currency
def get_calendar(self):
if self.calendar:
return self.calendar
if self.price_matrix:
return self.price_matrix.calendar
@staticmethod
def _record_id(record):
return getattr(record, 'id', record)
@classmethod
def _matches_optional_field(cls, matrix_line, field_name, target):
value = getattr(matrix_line, field_name, None)
if not value:
return True, 0
if target and cls._record_id(value) == cls._record_id(target):
return True, 1
return False, 0
def _matrix_valid_on(self, price_date):
if not self.price_matrix:
return False
if hasattr(price_date, 'date'):
price_date = price_date.date()
valid_from = getattr(self.price_matrix, 'valid_from', None)
valid_to = getattr(self.price_matrix, 'valid_to', None)
if valid_from and price_date < valid_from:
return False
if valid_to and price_date > valid_to:
return False
return True
def _get_matrix_owner_values(self):
line = getattr(self, 'line', None) or getattr(self, 'sale_line', None)
document = getattr(line, 'purchase', None) or getattr(line, 'sale', None)
return {
'origin': getattr(document, 'from_location', None),
'destination': getattr(document, 'to_location', None),
'product': getattr(line, 'product', None),
'quality': getattr(line, 'quality', None),
}
def _get_matrix_line(self):
MatrixLine = Pool().get('price.matrix.line')
lines = MatrixLine.search([('matrix', '=', self.price_matrix.id)])
owner_values = self._get_matrix_owner_values()
best_line = None
best_score = -1
for line in lines:
score = 0
for field_name, target in owner_values.items():
matches, field_score = self._matches_optional_field(
line, field_name, target)
if not matches:
break
score += field_score
else:
if score > best_score:
best_line = line
best_score = score
return best_line
def _convert_matrix_price(self, price, unit, currency):
price_qt = Decimal(str(price or 0))
matrix_unit = getattr(self.price_matrix, 'unit', None)
matrix_currency = getattr(self.price_matrix, 'currency', None)
if matrix_unit and unit:
Uom = Pool().get('product.uom')
price_qt *= Decimal(str(
Uom.compute_qty(unit, float(1), matrix_unit)))
if matrix_currency and currency and currency != matrix_currency:
Currency = Pool().get('currency.currency')
rates = Currency._get_rate([matrix_currency])
rate = rates.get(matrix_currency.id)
if rate:
price_qt *= Decimal(str(rate))
return round(price_qt, 4)
def get_price(self, price_date, unit, currency, last=False):
if self.price_source_type == 'curve' and self.price_index:
PI = Pool().get('price.price')
pi = PI(self.price_index)
return pi.get_price(price_date, unit, currency, last)
if self.price_source_type == 'matrix' and self.price_matrix:
if not self._matrix_valid_on(price_date):
return Decimal(0)
line = self._get_matrix_line()
if line:
return self._convert_matrix_price(
line.price_value, unit, currency)
return Decimal(0)
def get_nbdays(self, name):
days = 0
if self.triggers:
for t in self.triggers:
l,l2 = t.getApplicationListDates(self.get_calendar())
days += len(l)
return days
@classmethod
def delete(cls, components):
@@ -1171,17 +1267,20 @@ class Trigger(ModelSQL,ModelView):
current_date += datetime.timedelta(days=1)
return l, lprice
def getprice(self,current_date):
PI = Pool().get('price.price')
PC = Pool().get('pricing.component')
pc = PC(self.component)
pi = PI(pc.price_index)
val = {}
val['date'] = current_date
val['price'] = pi.get_price(current_date,pc.line.unit if pc.line else pc.sale_line.unit,pc.line.currency if pc.line else pc.sale_line.currency,self.last)
val['avg'] = val['price']
val['avg_minus_1'] = val['price']
val['isAvg'] = self.average
def getprice(self,current_date):
PC = Pool().get('pricing.component')
pc = PC(self.component)
val = {}
val['date'] = current_date
line = pc.line if pc.line else pc.sale_line
val['price'] = pc.get_price(
current_date,
line.unit,
line.currency,
self.last)
val['avg'] = val['price']
val['avg_minus_1'] = val['price']
val['isAvg'] = self.average
return val
class Period(ModelSQL,ModelView):

View File

@@ -219,7 +219,7 @@ class Summary(ModelSQL,ModelView):
Max(pp.fixed_qt+pp.unfixed_qt).as_('quantity'),
Max(pp.fixed_qt).as_('fixed_qt'),
(Min(pp.unfixed_qt)).as_('unfixed_qt'),
(Max(Case((pp.last, pp.eod_price),else_=0)) * Max(pc.ratio / 100)).as_('price'),
Max(Case((pp.last, pp.eod_price),else_=0)).as_('price'),
(Max(pp.fixed_qt)/Max(pp.fixed_qt+pp.unfixed_qt)).as_('progress'),
Max(pc.ratio).as_('ratio'),
#where=wh,
@@ -2120,18 +2120,19 @@ class Line(metaclass=PoolMeta):
Pricing._sync_manual_last(pricings)
Pricing._sync_eod_price(pricings)
if pc.triggers and pc.auto:
prDate = []
prPrice = []
apDate = []
apPrice = []
for t in pc.triggers:
prD, prP = t.getPricingListDates(pc.calendar)
apD, apP = t.getApplicationListDates(pc.calendar)
prDate.extend(prD)
prPrice.extend(prP)
apDate.extend(apD)
apPrice.extend(apP)
if pc.triggers and pc.auto:
prDate = []
prPrice = []
apDate = []
apPrice = []
calendar = pc.get_calendar()
for t in pc.triggers:
prD, prP = t.getPricingListDates(calendar)
apD, apP = t.getApplicationListDates(calendar)
prDate.extend(prD)
prPrice.extend(prP)
apDate.extend(apD)
apPrice.extend(apP)
if pc.quota:
prPrice = self.get_avg(prPrice)
self.generate_pricing(pc,apDate,prPrice)
@@ -2208,10 +2209,11 @@ class Line(metaclass=PoolMeta):
p.fixed_qt = base_quantity
if price > 0:
p.unfixed_qt_price = price
else:
pr = Decimal(pc.price_index.get_price(p.pricing_date,self.unit,self.purchase.currency,True))
pr = round(pr,4)
p.unfixed_qt_price = pr
else:
pr = Decimal(pc.get_price(
p.pricing_date, self.unit, self.purchase.currency, True))
pr = round(pr,4)
p.unfixed_qt_price = pr
p.eod_price = p.get_eod_price_purchase()
if (index == len(dl)-1) or (pc.pricing_date and (index < len(dl)-1 and dl_sorted[index+1].date() > pc.pricing_date)):
p.last = True

View File

@@ -1894,9 +1894,10 @@ class SaleLine(metaclass=PoolMeta):
prPrice = []
apDate = []
apPrice = []
calendar = pc.get_calendar()
for t in pc.triggers:
prD, prP = t.getPricingListDates(pc.calendar)
apD, apP = t.getApplicationListDates(pc.calendar)
prD, prP = t.getPricingListDates(calendar)
apD, apP = t.getApplicationListDates(calendar)
prDate.extend(prD)
prPrice.extend(prP)
apDate.extend(apD)
@@ -1981,7 +1982,8 @@ class SaleLine(metaclass=PoolMeta):
logger.info("GENERATE_1:%s",price)
p.unfixed_qt_price = price
else:
pr = Decimal(pc.price_index.get_price(p.pricing_date,self.unit,self.sale.currency,True))
pr = Decimal(pc.get_price(
p.pricing_date, self.unit, self.sale.currency, True))
pr = round(pr,4)
logger.info("GENERATE_2:%s",pr)
p.unfixed_qt_price = pr

View File

@@ -1148,8 +1148,12 @@ class PurchaseTradeTestCase(ModuleTestCase):
pricing_model = Mock()
pricing_model.search.return_value = []
pc_sale = Mock(id=1, quota_sale=Decimal('2'), pricing_date=None, price_index=Mock(get_price=Mock(return_value=Decimal('1'))))
pc_purchase = Mock(id=1, quota=Decimal('3'), pricing_date=None, price_index=Mock(get_price=Mock(return_value=Decimal('1'))))
pc_sale = Mock(
id=1, quota_sale=Decimal('2'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
pc_purchase = Mock(
id=1, quota=Decimal('3'), pricing_date=None,
get_price=Mock(return_value=Decimal('1')))
with patch('trytond.modules.purchase_trade.sale.Pool') as SalePool, patch(
'trytond.modules.purchase_trade.purchase.Pool') as PurchasePool:
@@ -1649,6 +1653,85 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(trigger.from_a, datetime.date(2026, 4, 1))
self.assertEqual(trigger.to_a, datetime.date(2026, 4, 30))
def test_pricing_component_matrix_returns_generic_line_price(self):
'matrix pricing can use an unconditional matrix line as a component price'
Component = Pool().get('pricing.component')
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=datetime.date(2026, 1, 1),
valid_to=datetime.date(2026, 12, 31),
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(),
purchase=Mock(from_location=Mock(), to_location=Mock()))
component.sale_line = None
matrix_line = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
matrix_model = Mock(search=Mock(return_value=[matrix_line]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('45.0000'))
def test_pricing_component_matrix_prefers_specific_line_over_generic(self):
'matrix pricing keeps generic lines as fallback below matching lines'
Component = Pool().get('pricing.component')
origin = Mock(id=1)
component = Component()
component.price_source_type = 'matrix'
component.price_matrix = Mock(
id=10,
valid_from=None,
valid_to=None,
unit=None,
currency=None,
)
component.line = Mock(
product=Mock(id=20),
purchase=Mock(from_location=origin, to_location=Mock(id=2)))
component.sale_line = None
generic = Mock(
origin=None,
destination=None,
product=None,
quality=None,
price_value=Decimal('45'))
specific = Mock(
origin=origin,
destination=None,
product=None,
quality=None,
price_value=Decimal('43'))
matrix_model = Mock(search=Mock(return_value=[generic, specific]))
with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
PricingPool.return_value.get.return_value = matrix_model
price = component.get_price(
datetime.date(2026, 4, 1), Mock(), Mock(), False)
self.assertEqual(price, Decimal('43.0000'))
def test_pricing_component_matrix_uses_matrix_calendar_fallback(self):
'matrix calendar is used when the component has no own calendar'
Component = Pool().get('pricing.component')
calendar = Mock()
component = Component()
component.calendar = None
component.price_matrix = Mock(calendar=calendar)
self.assertEqual(component.get_calendar(), calendar)
def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self):
'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories'
Sale = Pool().get('sale.sale')

View File

@@ -9,10 +9,11 @@
<field name="currency"/>
<label name="calendar"/>
<field name="calendar"/>
<newline/>
<label name="valid_from"/>
<field name="valid_from"/>
<label name="valid_to"/>
<field name="valid_to"/>
<newline/>
<field name="lines" colspan="4"/>
</form>
</form>