diff --git a/modules/purchase_trade/docs/business/pricing.md b/modules/purchase_trade/docs/business/pricing.md
index e56865e..46ac4d2 100644
--- a/modules/purchase_trade/docs/business/pricing.md
+++ b/modules/purchase_trade/docs/business/pricing.md
@@ -33,7 +33,7 @@ des lignes achat / vente.
Matrix
-
La matrix existe dans le modele mais n'est pas encore le moteur principal du fixing automatique.
+
Une matrix peut alimenter un composant de fixing, avec ligne generique ou conditions.
Pricing period
@@ -228,6 +228,14 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
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.
@@ -255,6 +263,10 @@ Source: `BR-PT-016` et doublon historique `BR-PT-015`
Cut-off
Pricing date max arrete la generation apres cette date.
+
+
Ratio negatif
+
Un ratio -100% soustrait le composant du prix basis.
+
@@ -307,6 +319,46 @@ Summary: derniere ligne marquee last
Exemple simplePricing 01/04 -> 05/04, 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.
+### 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 simpleCreer 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
@@ -389,7 +441,7 @@ Summary: derniere ligne marquee last
Matrix
-
Disponible dans le modele et le MTM, pas encore completement integree au fixing auto.
+
Disponible dans le fixing auto, avec fallback generique et priorite aux lignes conditionnelles.
prDate / apPrice
diff --git a/modules/purchase_trade/docs_source/business/pricing.md b/modules/purchase_trade/docs_source/business/pricing.md
index 03c6eb4..18fbeec 100644
--- a/modules/purchase_trade/docs_source/business/pricing.md
+++ b/modules/purchase_trade/docs_source/business/pricing.md
@@ -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. |
diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py
index 710280a..cb948e7 100755
--- a/modules/purchase_trade/pricing.py
+++ b/modules/purchase_trade/pricing.py
@@ -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):
diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py
index 9d39500..9a24bba 100755
--- a/modules/purchase_trade/purchase.py
+++ b/modules/purchase_trade/purchase.py
@@ -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
diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py
index 9ec5768..dbfe768 100755
--- a/modules/purchase_trade/sale.py
+++ b/modules/purchase_trade/sale.py
@@ -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
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index 679323a..81c033a 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -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')
diff --git a/modules/purchase_trade/view/price_matrix_form.xml b/modules/purchase_trade/view/price_matrix_form.xml
index ef115bd..4d80fc7 100644
--- a/modules/purchase_trade/view/price_matrix_form.xml
+++ b/modules/purchase_trade/view/price_matrix_form.xml
@@ -9,10 +9,11 @@
+
-
\ No newline at end of file
+