# Copyright 2025 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) """Calendar edge cases for the cutoff/pickup/delivery date chain. The date chain is `cutoff_date` -> `pickup_date` -> `delivery_date`: - `cutoff_date` is the next occurrence of `cutoff_day`, measured from `start_date` when it is in the future, or from **today** when `start_date` already passed. Recurring orders therefore roll forward to the current cycle instead of staying on the cycle they were created in. - `pickup_date` is the next occurrence of `pickup_day` *strictly after* `cutoff_date`. - `delivery_date` is `pickup_date` + 1 day. Every fixture below anchors on a **future** start date so the roll-forward does not move the cycle under the test, which keeps the arithmetic exact and the assertions stable over time. `test_past_weekly_order_rolls_forward` covers the roll-forward itself. """ from datetime import date from datetime import timedelta from dateutil.relativedelta import relativedelta from odoo.tests.common import TransactionCase class DateEdgeCaseCommon: """Helpers to build orders anchored on chosen calendar dates.""" def setUp(self): super().setUp() self.group = self.env["res.partner"].create( { "name": "Test Group", "is_company": True, } ) def _create_order(self, start_date, pickup_day, cutoff_day, **overrides): vals = { "name": "Date Edge Case Order", "group_ids": [(6, 0, [self.group.id])], "type": "regular", "start_date": start_date, "end_date": start_date + timedelta(days=7), "period": "weekly", "pickup_day": str(pickup_day), "cutoff_day": str(cutoff_day), } vals.update(overrides) return self.env["group.order"].create(vals) @staticmethod def _next_leap_day(reference): """First 29 February strictly after `reference`.""" year = reference.year while True: year += 1 try: candidate = date(year, 2, 29) except ValueError: continue if candidate > reference: return candidate @staticmethod def _expected_cutoff(start_date, cutoff_day): """Mirror of `_compute_cutoff_date` for a future weekly start.""" days_ahead = cutoff_day - start_date.weekday() if days_ahead < 0: days_ahead += 7 return start_date + timedelta(days=days_ahead) @staticmethod def _expected_pickup(cutoff_date, pickup_day): """Mirror of `_compute_pickup_date`: strictly after the cutoff.""" days_ahead = pickup_day - cutoff_date.weekday() if days_ahead <= 0: days_ahead += 7 return cutoff_date + timedelta(days=days_ahead) class TestLeapYearHandling(DateEdgeCaseCommon, TransactionCase): """A 29 February inside the cycle must not shift the dates.""" def test_pickup_lands_on_leap_day(self): """A pickup that falls exactly on 29 February is computed as such.""" leap_day = self._next_leap_day(date.today()) # Anchor the cycle so the cutoff is the day before the leap day; the # next pickup weekday is then the leap day itself. cutoff = leap_day - timedelta(days=1) start = cutoff - timedelta(days=1) order = self._create_order( start_date=start, pickup_day=leap_day.weekday(), cutoff_day=cutoff.weekday(), ) self.assertEqual(order.cutoff_date, cutoff) self.assertEqual(order.pickup_date, leap_day) self.assertEqual(order.pickup_date.day, 29) self.assertEqual(order.pickup_date.month, 2) def test_cycle_spans_the_leap_day(self): """A cycle crossing 29 February keeps the 7-day pickup spacing.""" leap_day = self._next_leap_day(date.today()) start = leap_day - timedelta(days=3) order = self._create_order( start_date=start, pickup_day=(start.weekday() + 4) % 7, cutoff_day=start.weekday(), ) # cutoff is the start day itself (days_ahead == 0 is allowed) self.assertEqual(order.cutoff_date, start) self.assertEqual(order.pickup_date, start + timedelta(days=4)) # The leap day is inside the cycle, so it was counted as a real day. self.assertLess(order.cutoff_date, leap_day) self.assertGreater(order.pickup_date, leap_day) def test_delivery_after_leap_day_pickup(self): """Delivery is pickup + 1 day even when pickup is 29 February.""" leap_day = self._next_leap_day(date.today()) cutoff = leap_day - timedelta(days=1) start = cutoff - timedelta(days=1) order = self._create_order( start_date=start, pickup_day=leap_day.weekday(), cutoff_day=cutoff.weekday(), ) self.assertEqual(order.pickup_date, leap_day) # 1 March in a leap year. self.assertEqual(order.delivery_date, date(leap_day.year, 3, 1)) class TestMonthAndYearBoundaries(DateEdgeCaseCommon, TransactionCase): """Cycles crossing a month or year boundary.""" def _future_day(self, target_day, month_offset=1): """A future date landing on `target_day` of some upcoming month.""" candidate = date.today().replace(day=1) + relativedelta(months=month_offset) return candidate.replace(day=target_day) def test_pickup_crosses_month_boundary(self): """A pickup in the month after the cutoff is computed correctly.""" # Anchor on the 28th so a few days forward always lands next month. start = self._future_day(28) pickup_day = (start.weekday() + 5) % 7 order = self._create_order( start_date=start, pickup_day=pickup_day, cutoff_day=start.weekday(), ) expected_pickup = start + timedelta(days=5) self.assertEqual(order.cutoff_date, start) self.assertEqual(order.pickup_date, expected_pickup) self.assertNotEqual(order.pickup_date.month, order.cutoff_date.month) def test_pickup_crosses_year_boundary(self): """A cycle spanning 31 December rolls into the next year.""" today = date.today() start = date(today.year + 1, 12, 29) pickup_day = (start.weekday() + 4) % 7 order = self._create_order( start_date=start, pickup_day=pickup_day, cutoff_day=start.weekday(), ) self.assertEqual(order.cutoff_date, start) self.assertEqual(order.pickup_date, start + timedelta(days=4)) self.assertEqual(order.pickup_date.year, start.year + 1) self.assertEqual(order.pickup_date.month, 1) def test_last_day_of_month_pickup(self): """A pickup landing on the last day of the month is kept intact.""" start = self._future_day(24) last_day = start + relativedelta(day=31) cutoff = last_day - timedelta(days=1) order = self._create_order( start_date=start, pickup_day=last_day.weekday(), cutoff_day=cutoff.weekday(), ) self.assertEqual(order.cutoff_date, cutoff) self.assertEqual(order.pickup_date, last_day) # Next day already belongs to the following month. self.assertEqual(order.delivery_date.day, 1) class TestMonthlyRecurrenceBoundaries(DateEdgeCaseCommon, TransactionCase): """The monthly grid must survive short months and leap years.""" def test_monthly_grid_from_january_31_anchor(self): """A 31 January anchor advances into February without overflowing.""" today = date.today() anchor = date(today.year + 1, 1, 31) order = self._create_order( start_date=anchor, pickup_day=(anchor.weekday() + 2) % 7, cutoff_day=anchor.weekday(), period="monthly", end_date=anchor + relativedelta(months=6), ) # First grid point is the anchor itself (already on cutoff_day). self.assertEqual(order.cutoff_date, anchor) self.assertEqual(order.pickup_date, anchor + timedelta(days=2)) def test_monthly_grid_lands_in_february_of_a_leap_year(self): """A February cycle in a leap year stays inside February.""" leap_day = self._next_leap_day(date.today()) anchor = date(leap_day.year, 2, 1) # Only meaningful while the anchor is still ahead of us. if anchor <= date.today(): self.skipTest("The next leap February already started") order = self._create_order( start_date=anchor, pickup_day=(anchor.weekday() + 1) % 7, cutoff_day=anchor.weekday(), period="monthly", end_date=anchor + relativedelta(months=6), ) self.assertEqual(order.cutoff_date, anchor) self.assertEqual(order.cutoff_date.month, 2) self.assertEqual(order.pickup_date, anchor + timedelta(days=1)) class TestRollForwardBehaviour(DateEdgeCaseCommon, TransactionCase): """Past-dated recurring orders move to the current cycle.""" def test_past_weekly_order_rolls_forward(self): """A weekly order created long ago computes dates for today's cycle. This is the documented behaviour of `_compute_cutoff_date`: once `start_date` is in the past the reference becomes today, so the order never keeps stale dates from the cycle it was created in. """ today = date.today() long_ago = today - relativedelta(years=2) order = self._create_order( start_date=long_ago, pickup_day=3, cutoff_day=0, end_date=long_ago + timedelta(days=7), ) expected_cutoff = self._expected_cutoff(today, 0) self.assertEqual(order.cutoff_date, expected_cutoff) self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) # The stale cycle is gone: nothing points back at the creation year. self.assertGreaterEqual(order.cutoff_date, today) self.assertGreater(order.pickup_date, today) def test_future_weekly_order_keeps_its_own_cycle(self): """A future order is anchored on its start date, not on today.""" start = date.today() + timedelta(days=30) order = self._create_order(start_date=start, pickup_day=3, cutoff_day=0) expected_cutoff = self._expected_cutoff(start, 0) self.assertEqual(order.cutoff_date, expected_cutoff) self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3)) self.assertGreaterEqual(order.cutoff_date, start)