From ddca542bfc59ce1d5d1060473a7f9024029c801e Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 25 May 2026 09:04:24 +0200 Subject: [PATCH] Pricing matrix --- .../purchase_trade/docs/business/pricing.md | 56 +++++- .../docs_source/business/pricing.md | 23 ++- modules/purchase_trade/pricing.py | 159 ++++++++++++++---- modules/purchase_trade/purchase.py | 36 ++-- modules/purchase_trade/sale.py | 8 +- modules/purchase_trade/tests/test_module.py | 87 +++++++++- .../purchase_trade/view/price_matrix_form.xml | 3 +- 7 files changed, 315 insertions(+), 57 deletions(-) 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CasRegle
Matrix generiqueUne ligne matrix sans origin, destination, product ni quality s'applique comme fallback global.
Matrix conditionnelleUne ligne avec origin, destination, product ou quality est prioritaire si elle correspond a la ligne trade.
Prixprice_value est converti vers l'unite / devise de la ligne si la matrix les renseigne.
Validitevalid_from et valid_to limitent la periode d'utilisation de la matrix.
CalendrierSi le composant n'a pas de calendrier, le calendrier de la matrix est utilise.
SoustractionPour 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 @@