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 @@