diff --git a/modules/price/price.py b/modules/price/price.py
index 1251a43..c74abc6 100755
--- a/modules/price/price.py
+++ b/modules/price/price.py
@@ -68,17 +68,22 @@ class Price(
amount = Decimal(self.get_price_per_qt(price,unit,currency)) * Decimal(Uom.compute_qty(self.price_unit, float(self.price_ct_size * nb_ct), unit))
return round(amount,4)
- def get_price(self,dt,unit,currency,last=False):
- price = float(0)
- PV = Pool().get('price.price_value')
- if self.price_values:
- dt = dt.strftime("%Y-%m-%d")
- pv = PV.search([('price','=',self.id),('price_date','=',dt)])
- if not pv and last:
- pv = PV.search([('price','=',self.id)],order=[('price_date', 'DESC')])
- if pv:
- price = self.get_price_per_qt(pv[0].price_value,unit,currency)
- return round(price,4)
+ def get_price(self,dt,unit,currency,last=False, relative_last=False):
+ price = float(0)
+ PV = Pool().get('price.price_value')
+ if self.price_values:
+ dt = dt.strftime("%Y-%m-%d")
+ pv = PV.search([('price','=',self.id),('price_date','=',dt)])
+ if not pv and last:
+ pv = PV.search([('price','=',self.id)],order=[('price_date', 'DESC')])
+ elif not pv and relative_last:
+ pv = PV.search([
+ ('price','=',self.id),
+ ('price_date','<=',dt),
+ ], order=[('price_date', 'DESC')])
+ if pv:
+ price = self.get_price_per_qt(pv[0].price_value,unit,currency)
+ return round(price,4)
class FixType(ModelSQL,ModelView):
"Fixation type"
diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py
index ec8c2c8..f5ef713 100755
--- a/modules/purchase_trade/pricing.py
+++ b/modules/purchase_trade/pricing.py
@@ -919,6 +919,7 @@ class Component(ModelSQL, ModelView):
'to_p': getattr(trigger, 'to_p', None),
'average': getattr(trigger, 'average', None),
'last': getattr(trigger, 'last', None),
+ 'relative_last': getattr(trigger, 'relative_last', None),
'application_period': cls._record_id(
getattr(trigger, 'application_period', None)),
'from_a': getattr(trigger, 'from_a', None),
@@ -1077,11 +1078,14 @@ class Component(ModelSQL, ModelView):
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)
+ def get_price(
+ self, price_date, unit, currency, last=False,
+ relative_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, relative_last)
if self.price_source_type == 'matrix' and self.price_matrix:
if not self._matrix_valid_on(price_date):
return Decimal(0)
@@ -1531,7 +1535,8 @@ class Trigger(ModelSQL,ModelView):
'readonly': Eval('pricing_period') != None,
})
average = fields.Boolean("Avg")
- last = fields.Boolean("Latest")
+ last = fields.Boolean("AL", help="Absolute Last")
+ relative_last = fields.Boolean("RL", help="Relative Last")
application_period = fields.Many2One('pricing.period',"Application period")
from_a = fields.Date("From",
states={
@@ -1561,26 +1566,26 @@ class Trigger(ModelSQL,ModelView):
if not self.application_period and not self.to_a:
self.to_a = self.to_p
- def getDateWithEstTrigger(self, period):
- PP = Pool().get('pricing.period')
- if period == 1:
- pp = PP(self.pricing_period)
- else:
- pp = PP(self.application_period)
+ def getDateWithEstTrigger(self, period, cal=None):
+ PP = Pool().get('pricing.period')
+ if period == 1:
+ pp = PP(self.pricing_period)
+ else:
+ pp = PP(self.application_period)
CO = Pool().get('pricing.component')
co = CO(self.component)
if co.line:
d = co.getEstimatedTriggerPurchase(pp.trigger)
- else:
- d = co.getEstimatedTriggerSale(pp.trigger)
- date_from,date_to,dates = pp.getDates(d)
- return date_from,date_to,d,pp.include,dates
-
- def getApplicationListDates(self, cal):
- ld = []
- dates = []
- if self.application_period:
- date_from, date_to, d, include,dates = self.getDateWithEstTrigger(2)
+ else:
+ d = co.getEstimatedTriggerSale(pp.trigger)
+ date_from,date_to,dates = pp.getDates(d, cal)
+ return date_from,date_to,d,pp.include,dates
+
+ def getApplicationListDates(self, cal):
+ ld = []
+ dates = []
+ if self.application_period:
+ date_from, date_to, d, include,dates = self.getDateWithEstTrigger(2, cal)
else:
date_from = self.from_a or self.from_p
date_to = self.to_a or self.to_p
@@ -1589,11 +1594,11 @@ class Trigger(ModelSQL,ModelView):
ld, lprice = self.getListDates(date_from,date_to,d,include,cal,2,dates)
return ld, lprice
- def getPricingListDates(self,cal):
- ld = []
- dates = []
- if self.pricing_period:
- date_from, date_to, d, include,dates = self.getDateWithEstTrigger(1)
+ def getPricingListDates(self,cal):
+ ld = []
+ dates = []
+ if self.pricing_period:
+ date_from, date_to, d, include,dates = self.getDateWithEstTrigger(1, cal)
else:
date_from = self.from_p#datetime.datetime(self.from_p.year, self.from_p.month, self.from_p.day)
date_to = self.to_p#datetime.datetime(self.to_p.year, self.to_p.month, self.to_p.day)
@@ -1636,10 +1641,11 @@ class Trigger(ModelSQL,ModelView):
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)
+ current_date,
+ line.unit,
+ line.currency,
+ self.last,
+ self.relative_last)
val['avg'] = val['price']
val['avg_minus_1'] = val['price']
val['isAvg'] = self.average
@@ -1677,12 +1683,22 @@ class Period(ModelSQL,ModelView):
def default_nbme(cls):
return 0
- def getDates(self,t):
- date_from = None
- date_to = None
- dates = []
- if t:
- if self.every:
+ @staticmethod
+ def _calendar_is_quote(cal, date):
+ if not cal:
+ return True
+ if not hasattr(cal, 'IsQuote'):
+ Calendar = Pool().get('price.calendar')
+ cal = Calendar(cal)
+ result = cal.IsQuote(date)
+ return True if result is None else bool(result)
+
+ def getDates(self,t, cal=None):
+ date_from = None
+ date_to = None
+ dates = []
+ if t:
+ if self.every:
if t:
j = self.every
if j not in WEEKDAY_MAP:
@@ -1705,14 +1721,23 @@ class Period(ModelSQL,ModelView):
current += datetime.timedelta(days=7)
elif self.nb_quotation < 0:
days_to_sub = (t.weekday() - weekday_target) % 7
- current = t - datetime.timedelta(days=days_to_sub)
- while len(dates) < -self.nb_quotation:
- dates.append(datetime.datetime(current.year, current.month, current.day))
- current -= datetime.timedelta(days=7)
-
- else:
- if self.startday == 'before':
- date_from = t - datetime.timedelta(days=(self.nbds if self.nbds else 0))
+ current = t - datetime.timedelta(days=days_to_sub)
+ while len(dates) < -self.nb_quotation:
+ dates.append(datetime.datetime(current.year, current.month, current.day))
+ current -= datetime.timedelta(days=7)
+
+ elif self.nb_quotation:
+ current = t - datetime.timedelta(days=1)
+ while len(dates) < abs(self.nb_quotation):
+ date = datetime.datetime(
+ current.year, current.month, current.day)
+ if self._calendar_is_quote(cal, date):
+ dates.append(date)
+ current -= datetime.timedelta(days=1)
+ dates.reverse()
+ else:
+ if self.startday == 'before':
+ date_from = t - datetime.timedelta(days=(self.nbds if self.nbds else 0))
elif self.startday == 'after':
date_from = t + datetime.timedelta(days=(self.nbds if self.nbds else 0))
elif self.startday == 'first':
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index 8bc6cfb..e5dd075 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -3,7 +3,7 @@
import datetime
from decimal import Decimal
-from unittest.mock import Mock, patch
+from unittest.mock import ANY, Mock, patch
from xml.etree import ElementTree
from trytond.pool import Pool
@@ -3321,6 +3321,89 @@ class PurchaseTradeTestCase(ModuleTestCase):
])
self.assertEqual(len(prices), 3)
+ def test_pricing_period_every_positive_returns_weekdays_after_trigger(self):
+ 'Every Friday and 4 returns the four Fridays from/after trigger date'
+ Period = Pool().get('pricing.period')
+ period = Period()
+ period.trigger = 'bldate'
+ period.every = 'friday'
+ period.nb_quotation = 4
+
+ _, _, dates = period.getDates(datetime.date(2026, 4, 1))
+
+ self.assertEqual(
+ [date.date() for date in dates],
+ [
+ datetime.date(2026, 4, 3),
+ datetime.date(2026, 4, 10),
+ datetime.date(2026, 4, 17),
+ datetime.date(2026, 4, 24),
+ ])
+
+ def test_pricing_period_every_negative_returns_weekdays_before_trigger(self):
+ 'Every Friday and -4 returns the four Fridays before trigger date'
+ Period = Pool().get('pricing.period')
+ period = Period()
+ period.trigger = 'bldate'
+ period.every = 'friday'
+ period.nb_quotation = -4
+
+ _, _, dates = period.getDates(datetime.date(2026, 4, 30))
+
+ self.assertEqual(
+ [date.date() for date in dates],
+ [
+ datetime.date(2026, 4, 24),
+ datetime.date(2026, 4, 17),
+ datetime.date(2026, 4, 10),
+ datetime.date(2026, 4, 3),
+ ])
+
+ def test_pricing_period_blank_every_uses_available_dates_before_trigger(self):
+ 'Blank Every and Nb quotation returns available dates before trigger'
+ Period = Pool().get('pricing.period')
+ calendar = Mock()
+ excluded = {
+ datetime.date(2026, 4, 8),
+ datetime.date(2026, 4, 10),
+ }
+ calendar.IsQuote = Mock(
+ side_effect=lambda date: date.date() not in excluded)
+ period = Period()
+ period.trigger = 'bldate'
+ period.every = None
+ period.nb_quotation = 4
+
+ _, _, dates = period.getDates(datetime.date(2026, 4, 13), calendar)
+
+ self.assertEqual(
+ [date.date() for date in dates],
+ [
+ datetime.date(2026, 4, 6),
+ datetime.date(2026, 4, 7),
+ datetime.date(2026, 4, 9),
+ datetime.date(2026, 4, 12),
+ ])
+
+ def test_pricing_period_blank_every_negative_uses_available_dates_before_trigger(self):
+ 'Blank Every and negative Nb quotation also returns dates before trigger'
+ Period = Pool().get('pricing.period')
+ period = Period()
+ period.trigger = 'bldate'
+ period.every = None
+ period.nb_quotation = -4
+
+ _, _, dates = period.getDates(datetime.date(2026, 4, 13))
+
+ self.assertEqual(
+ [date.date() for date in dates],
+ [
+ datetime.date(2026, 4, 9),
+ datetime.date(2026, 4, 10),
+ datetime.date(2026, 4, 11),
+ datetime.date(2026, 4, 12),
+ ])
+
def test_purchase_line_get_avg_accepts_decimal_matrix_prices(self):
'purchase pricing averages Decimal matrix prices without float mixing'
PurchaseLine = Pool().get('purchase.line')
@@ -3388,12 +3471,13 @@ 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_trigger_average_defaults_to_true_and_last_label_is_latest(self):
- 'pricing trigger defaults to Avg and labels last as Latest'
+ def test_pricing_trigger_average_defaults_to_true_and_last_modes(self):
+ 'pricing trigger defaults to Avg and exposes AL/RL last modes'
Trigger = Pool().get('pricing.trigger')
self.assertTrue(Trigger.default_average())
- self.assertEqual(Trigger.last.string, 'Latest')
+ self.assertEqual(Trigger.last.string, 'AL')
+ self.assertEqual(Trigger.relative_last.string, 'RL')
@with_transaction()
def test_import_forward_prices_copies_value_to_ohl_fields(self):
@@ -3457,6 +3541,48 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(price, Decimal('45.0000'))
+ def test_pricing_component_curve_passes_relative_last(self):
+ 'curve pricing can request the price relative last before target date'
+ Component = Pool().get('pricing.component')
+ price_index = Mock()
+ component = Component()
+ component.price_source_type = 'curve'
+ component.price_index = price_index
+ price_model = Mock(return_value=price_index)
+ price_index.get_price = Mock(return_value=Decimal('101'))
+
+ with patch('trytond.modules.purchase_trade.pricing.Pool') as PricingPool:
+ PricingPool.return_value.get.return_value = price_model
+ price = component.get_price(
+ datetime.date(2026, 4, 1), Mock(), Mock(), False, True)
+
+ self.assertEqual(price, Decimal('101'))
+ price_index.get_price.assert_called_once_with(
+ datetime.date(2026, 4, 1), ANY, ANY, False, True)
+
+ def test_price_get_price_relative_last_uses_latest_before_date(self):
+ 'relative last picks latest available price before target date'
+ Price = Pool().get('price.price')
+ price = Price()
+ price.id = 42
+ price.price_values = [Mock()]
+ price.get_price_per_qt = Mock(return_value=Decimal('88'))
+ previous_value = Mock(price_value=Decimal('88'))
+ price_value_model = Mock()
+ price_value_model.search = Mock(side_effect=[[], [previous_value]])
+
+ with patch('trytond.modules.price.price.Pool') as PricePool:
+ PricePool.return_value.get.return_value = price_value_model
+ result = price.get_price(
+ datetime.date(2026, 4, 10), Mock(), Mock(),
+ last=False, relative_last=True)
+
+ self.assertEqual(result, Decimal('88'))
+ price_value_model.search.assert_any_call([
+ ('price', '=', 42),
+ ('price_date', '<=', '2026-04-10'),
+ ], order=[('price_date', 'DESC')])
+
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')
diff --git a/modules/purchase_trade/view/trigger_form.xml b/modules/purchase_trade/view/trigger_form.xml
index d4e6eb9..8e9168e 100755
--- a/modules/purchase_trade/view/trigger_form.xml
+++ b/modules/purchase_trade/view/trigger_form.xml
@@ -5,11 +5,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/modules/purchase_trade/view/trigger_tree.xml b/modules/purchase_trade/view/trigger_tree.xml
index 107984c..54d17fa 100755
--- a/modules/purchase_trade/view/trigger_tree.xml
+++ b/modules/purchase_trade/view/trigger_tree.xml
@@ -1,10 +1,11 @@
-
-
-
-
+
+
+
+
+
diff --git a/modules/purchase_trade/view/trigger_tree_sequence.xml b/modules/purchase_trade/view/trigger_tree_sequence.xml
index 027fbe0..d994504 100755
--- a/modules/purchase_trade/view/trigger_tree_sequence.xml
+++ b/modules/purchase_trade/view/trigger_tree_sequence.xml
@@ -1,9 +1,11 @@
-
-
-
-
+
+
+
+
+
+