From d705d86e808c655204ed45128ba56677c03ca6a8 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 15 Jun 2026 08:28:59 +0200 Subject: [PATCH] Pricing Before/After --- modules/purchase_trade/pricing.py | 68 +++++++++++----- modules/purchase_trade/tests/test_module.py | 90 +++++++++++++++++++++ 2 files changed, 140 insertions(+), 18 deletions(-) diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index b0e8262..067f408 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -48,11 +48,12 @@ WEEKDAY_MAP = { 'sunday': 6 } -DAYS = [ - (None,''), - ('monday', 'Monday'), - ('tuesday', 'Tuesday'), - ('wednesday', 'Wednesday'), +DAYS = [ + (None,''), + ('before_after', 'Before/After'), + ('monday', 'Monday'), + ('tuesday', 'Tuesday'), + ('wednesday', 'Wednesday'), ('thursday', 'Thursday'), ('friday', 'Friday'), ('saturday', 'Saturday'), @@ -1695,16 +1696,46 @@ class Period(ModelSQL,ModelView): result = cal.IsQuote(date) return True if result is None else bool(result) + @staticmethod + def _as_datetime(date): + return datetime.datetime(date.year, date.month, date.day) + + def _available_quotation_dates(self, trigger_date, count, step, cal=None): + dates = [] + current = trigger_date + datetime.timedelta(days=step) + while len(dates) < count: + date = self._as_datetime(current) + if self._calendar_is_quote(cal, date): + dates.append(date) + current += datetime.timedelta(days=step) + if step < 0: + dates.reverse() + return dates + 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: - raise ValueError(f"Invalid day : '{j}'") + if t: + j = self.every + if j == 'before_after' and self.nb_quotation: + count = abs(self.nb_quotation) + dates.extend( + self._available_quotation_dates( + t, count, -1, cal)) + trigger_date = self._as_datetime(t) + if (self.include + and self._calendar_is_quote( + cal, trigger_date)): + dates.append(trigger_date) + dates.extend( + self._available_quotation_dates( + t, count, 1, cal)) + return date_from, date_to, dates + if j not in WEEKDAY_MAP: + raise ValueError(f"Invalid day : '{j}'") weekday_target = WEEKDAY_MAP[j] @@ -1730,15 +1761,16 @@ class Period(ModelSQL,ModelView): elif self.nb_quotation: step = 1 if self.nb_quotation > 0 else -1 - current = t + datetime.timedelta(days=step) - 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=step) - if self.nb_quotation < 0: - dates.reverse() + dates.extend( + self._available_quotation_dates( + t, abs(self.nb_quotation), step, cal)) + trigger_date = self._as_datetime(t) + if (self.include + and self._calendar_is_quote(cal, trigger_date)): + if self.nb_quotation > 0: + dates.insert(0, trigger_date) + else: + dates.append(trigger_date) else: if self.startday == 'before': date_from = t - datetime.timedelta(days=(self.nbds if self.nbds else 0)) diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index d78c226..c68dbe3 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -3385,6 +3385,27 @@ class PurchaseTradeTestCase(ModuleTestCase): datetime.date(2026, 5, 18), ]) + def test_pricing_period_blank_every_include_counts_trigger_plus_after_dates(self): + 'Blank Every with Inc. returns trigger date plus quotations after' + Period = Pool().get('pricing.period') + period = Period() + period.trigger = 'bldate' + period.include = True + period.every = None + period.nb_quotation = 4 + + _, _, dates = period.getDates(datetime.date(2026, 5, 12)) + + self.assertEqual( + [date.date() for date in dates], + [ + datetime.date(2026, 5, 12), + datetime.date(2026, 5, 13), + datetime.date(2026, 5, 14), + datetime.date(2026, 5, 15), + datetime.date(2026, 5, 16), + ]) + 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') @@ -3404,6 +3425,75 @@ class PurchaseTradeTestCase(ModuleTestCase): datetime.date(2026, 5, 11), ]) + def test_pricing_period_before_after_returns_symmetric_dates(self): + 'Before/After returns quotations before and after trigger' + Period = Pool().get('pricing.period') + period = Period() + period.trigger = 'bldate' + period.include = False + period.every = 'before_after' + period.nb_quotation = 2 + + _, _, dates = period.getDates(datetime.date(2026, 5, 12)) + + self.assertEqual( + [date.date() for date in dates], + [ + datetime.date(2026, 5, 10), + datetime.date(2026, 5, 11), + datetime.date(2026, 5, 13), + datetime.date(2026, 5, 14), + ]) + + def test_pricing_period_before_after_include_adds_trigger_date(self): + 'Before/After with Inc. adds trigger date between both sides' + Period = Pool().get('pricing.period') + period = Period() + period.trigger = 'bldate' + period.include = True + period.every = 'before_after' + period.nb_quotation = 2 + + _, _, dates = period.getDates(datetime.date(2026, 5, 12)) + + self.assertEqual( + [date.date() for date in dates], + [ + datetime.date(2026, 5, 10), + datetime.date(2026, 5, 11), + datetime.date(2026, 5, 12), + datetime.date(2026, 5, 13), + datetime.date(2026, 5, 14), + ]) + + def test_pricing_period_before_after_skips_calendar_exclusions(self): + 'Before/After uses available quotations around trigger date' + Period = Pool().get('pricing.period') + calendar = Mock() + excluded = { + datetime.date(2026, 5, 11), + datetime.date(2026, 5, 13), + } + calendar.IsQuote = Mock( + side_effect=lambda date: date.date() not in excluded) + period = Period() + period.trigger = 'bldate' + period.include = True + period.every = 'before_after' + period.nb_quotation = 2 + + _, _, dates = period.getDates(datetime.date(2026, 5, 12), calendar) + + self.assertEqual( + [date.date() for date in dates], + [ + datetime.date(2026, 5, 9), + datetime.date(2026, 5, 10), + datetime.date(2026, 5, 12), + datetime.date(2026, 5, 14), + datetime.date(2026, 5, 15), + ]) + def test_pricing_trigger_application_period_defaults_to_pricing_period(self): 'application period defaults to pricing period without being cleared' Trigger = Pool().get('pricing.trigger')