From aba22fd23065a1d3095d8b2cc26031dc919079b4 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Wed, 15 Jul 2026 14:14:03 +0200 Subject: [PATCH] [IMP] website_sale_aplicoop: automate non-weekly group order cycles One-time, biweekly and monthly group orders now follow the same cron confirmation flow as weekly ones (confirm sale orders + batch pickings when the cycle cutoff passes): - Biweekly/monthly keep the cutoff_day/pickup_day weekday scheme on a recurrence grid anchored at start_date (creation date as fallback): cutoffs advance +14 days / +1 month snapped to cutoff_day, with catch-up after cron downtime. Previously they behaved as weekly. - One-time orders (specials/promotions) are driven by end_date (cutoff_date = end_date); once passed, the cron confirms, batches and closes the group order. - end_date keeps its "empty = permanent" meaning for recurring orders. - Website draft-cart lookup window is now period-aware instead of assuming a 6-day weekly cycle. - New cron tests for once/biweekly/monthly cycles; i18n es/eu updated. Co-Authored-By: Claude Fable 5 --- website_sale_aplicoop/CHANGELOG.md | 23 ++ website_sale_aplicoop/__manifest__.py | 2 +- .../controllers/website_sale_validators.py | 24 +- website_sale_aplicoop/i18n/es.po | 45 ++- website_sale_aplicoop/i18n/eu.po | 44 ++- website_sale_aplicoop/models/group_order.py | 132 +++++++- .../tests/test_cron_picking_batch.py | 310 ++++++++++++++++-- .../views/group_order_views.xml | 4 +- 8 files changed, 510 insertions(+), 74 deletions(-) diff --git a/website_sale_aplicoop/CHANGELOG.md b/website_sale_aplicoop/CHANGELOG.md index 1eaa993..5972f82 100644 --- a/website_sale_aplicoop/CHANGELOG.md +++ b/website_sale_aplicoop/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - Website Sale Aplicoop +## [18.0.1.11.0] - 2026-07-15 + +### Added + +- **Non-weekly cycle automation**: one-time (special/promotional), biweekly and + monthly group orders now follow the same cron confirmation flow as weekly orders + (confirm linked sale orders + batch their pickings once the cycle cutoff passes). +- **Biweekly/monthly recurrence**: these periods keep the weekday scheme + (`cutoff_day`/`pickup_day`) but on a recurrence grid anchored at `start_date` + (creation date as fallback): cutoffs advance every 14 days / 1 month (snapped to + `cutoff_day`), with automatic catch-up if the cron was down. Previously they + behaved as weekly. +- **One-time orders**: their single cycle is driven by `end_date` + (`cutoff_date = end_date`); once it passes, the cron confirms, batches and closes + the group order automatically. + +### Changed + +- `end_date` keeps its "permanent if empty" meaning for all recurring periods; + it only closes the cycle for one-time orders. +- Draft-cart lookup window on the website is now period-aware (7/14 days, 1 month, + or the full order lifetime for one-time orders) instead of always 6 days. + ## [18.0.1.9.0] - 2026-05-20 ### Added diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index f7ac586..0140a35 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -3,7 +3,7 @@ { # noqa: B018 "name": "Website Sale - Aplicoop", - "version": "18.0.1.10.2", + "version": "18.0.1.11.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", diff --git a/website_sale_aplicoop/controllers/website_sale_validators.py b/website_sale_aplicoop/controllers/website_sale_validators.py index 940a604..8f14309 100644 --- a/website_sale_aplicoop/controllers/website_sale_validators.py +++ b/website_sale_aplicoop/controllers/website_sale_validators.py @@ -65,27 +65,39 @@ def _get_salesperson_for_order(self, partner): def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): """Return the active-cycle draft sale.order for the partner, or empty. - The active ordering period ends at group_order.cutoff_date and begins - 6 days earlier (weekly cycle). Drafts created outside this window belong - to a previous cycle and must not be reused — otherwise stale carts come - back when the user re-enters the order page. + The active ordering period ends at group_order.cutoff_date and its length + depends on the order period: 7 days (weekly), 14 days (biweekly), one + month (monthly). One-time orders have a single cycle starting at + start_date (or unbounded when start_date is empty). Drafts created + outside this window belong to a previous cycle and must not be reused — + otherwise stale carts come back when the user re-enters the order page. """ req = request_obj or request from datetime import timedelta + from dateutil.relativedelta import relativedelta + if not group_order or not group_order.cutoff_date: return req.env["sale.order"] - period_start = group_order.cutoff_date - timedelta(days=6) period_end = group_order.cutoff_date + if group_order.period == "weekly": + period_start = period_end - timedelta(days=6) + elif group_order.period == "biweekly": + period_start = period_end - timedelta(days=13) + elif group_order.period == "monthly": + period_start = period_end - relativedelta(months=1) + timedelta(days=1) + else: # once: single cycle, bounded by start_date when set + period_start = group_order.start_date domain = [ ("partner_id", "=", partner_id), ("group_order_id", "=", group_order.id), ("state", "=", "draft"), - ("create_date", ">=", f"{period_start} 00:00:00"), ("create_date", "<=", f"{period_end} 23:59:59"), ] + if period_start: + domain.append(("create_date", ">=", f"{period_start} 00:00:00")) return ( req.env["sale.order"].sudo().search(domain, order="create_date desc", limit=1) diff --git a/website_sale_aplicoop/i18n/es.po b/website_sale_aplicoop/i18n/es.po index c2f590b..e904244 100644 --- a/website_sale_aplicoop/i18n/es.po +++ b/website_sale_aplicoop/i18n/es.po @@ -663,26 +663,39 @@ msgstr "Día en que las compras se detienen" #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day msgid "" -"Day when purchases stop and the consumer group order is locked for this " -"week." +"Day when purchases stop and the consumer group order is locked for the " +"current cycle." msgstr "" -"Día en que se detienen las compras y el pedido del grupo de consumidores se " -"bloquea para esta semana." +"Día en que se detienen las compras y el pedido de grupo se bloquea para el " +"ciclo actual." #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date -msgid "Day when the consumer group order opens for purchases" -msgstr "Día en que el pedido del grupo de consumidores se abre para compras" +msgid "" +"Day when the consumer group order opens for purchases. For biweekly and " +"monthly orders it also anchors the recurrence: cycles advance every 14 days " +"/ 1 month from the first cutoff day after this date." +msgstr "" +"Día en que el pedido de grupo se abre para compras. En pedidos quincenales y" +" mensuales también ancla la recurrencia: los ciclos avanzan cada 14 días / 1" +" mes desde el primer día de corte posterior a esta fecha." #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form -msgid "Day when the order closes (empty = permanent)" -msgstr "Día en que el pedido se cierra (vacío = permanente)" +msgid "" +"Day when the order closes (empty = permanent). One-time orders are confirmed" +" and closed once it has passed" +msgstr "" +"Día en que se cierra el pedido (vacío = permanente). Los pedidos puntuales " +"se confirman y cierran una vez pasada la fecha" #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form -msgid "Day when the order opens for purchases" -msgstr "Día en que el pedido se abre para compras" +msgid "" +"Day when the order opens for purchases (anchors biweekly/monthly cycles)" +msgstr "" +"Día en que el pedido se abre para compras (ancla los ciclos " +"quincenales/mensuales)" #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop_products @@ -1120,8 +1133,16 @@ msgstr "Si está marcado, algunos mensajes tienen un error de entrega." #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date -msgid "If empty, the consumer group order is permanent" -msgstr "Si está vacío, el pedido del grupo de consumidores es permanente" +msgid "" +"Recurring orders (weekly, biweekly, monthly): if empty, the consumer group " +"order is permanent. One-time orders: this date closes the single cycle; once" +" it has passed, the cron confirms the linked sale orders and closes the " +"order." +msgstr "" +"Pedidos recurrentes (semanal, quincenal, mensual): si está vacía, el pedido " +"de grupo es permanente. Pedidos puntuales: esta fecha cierra el ciclo único;" +" una vez pasada, el cron confirma los pedidos de venta vinculados y cierra " +"el pedido." #. module: website_sale_aplicoop #: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image diff --git a/website_sale_aplicoop/i18n/eu.po b/website_sale_aplicoop/i18n/eu.po index def924e..bda4097 100644 --- a/website_sale_aplicoop/i18n/eu.po +++ b/website_sale_aplicoop/i18n/eu.po @@ -661,26 +661,38 @@ msgstr "Erosketak gelditzen diren eguna" #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day msgid "" -"Day when purchases stop and the consumer group order is locked for this " -"week." +"Day when purchases stop and the consumer group order is locked for the " +"current cycle." msgstr "" -"Erosketak gelditzen diren eguna eta kontsumitzaileen talde eskaera aste " -"honetarako " +"Erosketak gelditzen diren eguna, talde-eskaera uneko ziklorako blokeatuz." #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date -msgid "Day when the consumer group order opens for purchases" -msgstr "Erosketak egiteko kontsumitzaileen talde eskaera irekitzeko eguna" +msgid "" +"Day when the consumer group order opens for purchases. For biweekly and " +"monthly orders it also anchors the recurrence: cycles advance every 14 days " +"/ 1 month from the first cutoff day after this date." +msgstr "" +"Talde-eskaera erosketetarako irekitzen den eguna. Hamabostekari eta hileko " +"eskaeretan errepikapena ere ainguratzen du: zikloak 14 egunero / hilabetero " +"aurreratzen dira, data honen ondorengo lehen mozketa-egunetik." #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form -msgid "Day when the order closes (empty = permanent)" -msgstr "Eskaera itxi den eguna (hutsik = iraunkorra)" +msgid "" +"Day when the order closes (empty = permanent). One-time orders are confirmed" +" and closed once it has passed" +msgstr "" +"Eskaera ixten den eguna (hutsik = iraunkorra). Behin bakarreko eskaerak data" +" pasatutakoan berretsi eta ixten dira" #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form -msgid "Day when the order opens for purchases" -msgstr "Erosketak egiteko eskaera irekitzeko eguna" +msgid "" +"Day when the order opens for purchases (anchors biweekly/monthly cycles)" +msgstr "" +"Eskaera erosketetarako irekitzen den eguna (hamabostekari/hileko zikloak " +"ainguratzen ditu)" #. module: website_sale_aplicoop #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop_products @@ -1118,8 +1130,16 @@ msgstr "Baloratuta badago, mezu batzuek entrega errorea dute." #. module: website_sale_aplicoop #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date -msgid "If empty, the consumer group order is permanent" -msgstr "Hutsik badago, kontsumitzaileen talde eskaera iraunkorra da" +msgid "" +"Recurring orders (weekly, biweekly, monthly): if empty, the consumer group " +"order is permanent. One-time orders: this date closes the single cycle; once" +" it has passed, the cron confirms the linked sale orders and closes the " +"order." +msgstr "" +"Eskaera errepikariak (astekoa, hamabostekaria, hilekoa): hutsik badago, " +"talde-eskaera iraunkorra da. Behin bakarreko eskaerak: data honek ziklo " +"bakarra ixten du; pasatutakoan, cron-ak lotutako salmenta-eskaerak berretsi " +"eta eskaera ixten du." #. module: website_sale_aplicoop #: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 97dad3c..ef81ddd 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -4,6 +4,8 @@ import logging from datetime import timedelta +from dateutil.relativedelta import relativedelta + from odoo import api from odoo import fields from odoo import models @@ -100,12 +102,17 @@ class GroupOrder(models.Model): start_date = fields.Date( required=False, tracking=True, - help="Day when the consumer group order opens for purchases", + help="Day when the consumer group order opens for purchases. For biweekly " + "and monthly orders it also anchors the recurrence: cycles advance every " + "14 days / 1 month from the first cutoff day after this date.", ) end_date = fields.Date( required=False, tracking=True, - help="If empty, the consumer group order is permanent", + help="Recurring orders (weekly, biweekly, monthly): if empty, the consumer " + "group order is permanent. One-time orders: this date closes the single " + "cycle; once it has passed, the cron confirms the linked sale orders and " + "closes the order.", ) # === Período y días === @@ -126,7 +133,7 @@ class GroupOrder(models.Model): selection=_get_day_selection, required=False, tracking=True, - help="Day when purchases stop and the consumer group order is locked for this week.", + help="Day when purchases stop and the consumer group order is locked for the current cycle.", ) # === Home delivery === @@ -743,24 +750,52 @@ class GroupOrder(models.Model): reference_date, ) - @api.depends("cutoff_day", "start_date") + @staticmethod + def _next_weekday_on_or_after(day, weekday): + """Return the first date on/after `day` that falls on `weekday` (0=Monday).""" + return day + timedelta(days=(weekday - day.weekday()) % 7) + + @api.depends("cutoff_day", "start_date", "period", "end_date") def _compute_cutoff_date(self): """Compute the cutoff date (deadline to place orders before pickup). - The cutoff date is the NEXT occurrence of cutoff_day from today. - This is when members can no longer place orders. + Recurring orders (weekly, biweekly, monthly) use the weekday scheme: + cutoff_date always falls on cutoff_day. - Example (as of Monday 2026-02-09): - - cutoff_day = 6 (Sunday) → cutoff_date = 2026-02-15 (next Sunday) - - pickup_day = 1 (Tuesday) → pickup_date = 2026-02-17 (Tuesday after cutoff) + - Weekly: the NEXT occurrence of cutoff_day from today. + + Example (as of Monday 2026-02-09): + - cutoff_day = 6 (Sunday) → cutoff_date = 2026-02-15 (next Sunday) + - pickup_day = 1 (Tuesday) → pickup_date = 2026-02-17 (Tuesday after) + + - Biweekly/monthly: cycles follow a recurrence grid anchored at + start_date (or the creation date as fallback). The first cutoff is + the first cutoff_day on/after the anchor; each next cutoff advances + +14 days / +1 month (snapped forward to cutoff_day). The computed + value is the first grid occurrence that is today or later, so the + grid stays stable across daily recomputes and catches up after + downtime. + + One-time orders (once) have a single cycle driven by the end date: + cutoff_date = end_date. In every case the cron confirms sale orders + once cutoff_date has passed, sharing one confirmation flow. """ from datetime import datetime _logger.info("_compute_cutoff_date called for %d records", len(self)) + today = datetime.now().date() for record in self: - if record.cutoff_day: + if record.period == "once": + record.cutoff_date = record.end_date or None + _logger.info( + "Computed cutoff_date for order %d from end_date: %s (period=once)", + record.id, + record.cutoff_date, + ) + elif not record.cutoff_day: + record.cutoff_date = None + elif record.period == "weekly": target_weekday = int(record.cutoff_day) - today = datetime.now().date() # Use today as reference if start_date is in the past, otherwise use start_date if record.start_date and record.start_date < today: @@ -789,7 +824,41 @@ class GroupOrder(models.Model): days_ahead, ) else: - record.cutoff_date = None + # Biweekly/monthly recurrence grid anchored at start_date + # (creation date as fallback so the grid stays stable). + target_weekday = int(record.cutoff_day) + anchor = ( + record.start_date + or (record.create_date and record.create_date.date()) + or today + ) + first_cutoff = self._next_weekday_on_or_after(anchor, target_weekday) + + if first_cutoff >= today: + cutoff = first_cutoff + elif record.period == "biweekly": + elapsed_days = (today - first_cutoff).days + cycles = (elapsed_days + 13) // 14 # ceil to next grid point + cutoff = first_cutoff + timedelta(days=14 * cycles) + else: # monthly + cutoff = first_cutoff + months = 0 + while cutoff < today: + months += 1 + cutoff = self._next_weekday_on_or_after( + first_cutoff + relativedelta(months=months), + target_weekday, + ) + + record.cutoff_date = cutoff + _logger.info( + "Computed cutoff_date for order %d: %s (period=%s, anchor=%s, first=%s)", + record.id, + record.cutoff_date, + record.period, + anchor, + first_cutoff, + ) @api.depends("pickup_date") def _compute_delivery_date(self): @@ -812,12 +881,12 @@ class GroupOrder(models.Model): # === Onchange Methods === - @api.onchange("cutoff_day", "start_date") + @api.onchange("cutoff_day", "start_date", "period", "end_date") def _onchange_cutoff_day(self): """Force recompute cutoff_date on UI change for immediate feedback.""" self._compute_cutoff_date() - @api.onchange("pickup_day", "cutoff_day", "start_date") + @api.onchange("pickup_day", "cutoff_day", "start_date", "period", "end_date") def _onchange_pickup_day(self): """Force recompute pickup_date on UI change for immediate feedback.""" self._compute_pickup_date() @@ -863,8 +932,10 @@ class GroupOrder(models.Model): try: # Confirm BEFORE recomputing dates: cutoff_date still points to the # current cycle's cutoff (today or past), so the check works correctly. - # After confirmation, recompute dates so they advance to the next cycle. + # After confirmation, close finished one-time orders and recompute + # dates so recurring orders move to the next cycle. order._confirm_linked_sale_orders() + order._close_one_time_order_if_ended() order._compute_cutoff_date() order._compute_pickup_date() order._compute_delivery_date() @@ -892,6 +963,31 @@ class GroupOrder(models.Model): failed_orders, ) + def _close_one_time_order_if_ended(self): + """Close one-time orders once their end_date has passed. + + Called by the daily cron right after _confirm_linked_sale_orders(), so + the single cycle has already been confirmed and batched. Recurring + orders (weekly, biweekly, monthly) are untouched: their cycle advances + through the weekday-based cutoff/pickup recomputation. + """ + self.ensure_one() + + today = fields.Date.today() + if ( + self.period == "once" + and self.state == "open" + and self.end_date + and self.end_date < today + ): + self.write({"state": "closed"}) + _logger.info( + "Cron: Closed one-time group order %s (%s) - end date %s passed", + self.id, + self.name, + self.end_date, + ) + def _confirm_linked_sale_orders(self): """Confirm draft/sent sale orders linked to this group order. @@ -907,13 +1003,17 @@ class GroupOrder(models.Model): if not self.cutoff_date: _logger.warning( - "Cron: Group order %s (%s) has no cutoff_date (state=%s, period=%s, cutoff_day=%s, start_date=%s). Skipping sale order confirmation for this cycle.", + "Cron: Group order %s (%s) has no cutoff_date (state=%s, period=%s, " + "cutoff_day=%s, start_date=%s, end_date=%s). Recurring orders need " + "a cutoff_day and one-time orders an end_date to close their cycle. " + "Skipping sale order confirmation for this cycle.", self.id, self.name, self.state, self.period, self.cutoff_day, self.start_date, + self.end_date, ) return diff --git a/website_sale_aplicoop/tests/test_cron_picking_batch.py b/website_sale_aplicoop/tests/test_cron_picking_batch.py index 152be12..65b111b 100644 --- a/website_sale_aplicoop/tests/test_cron_picking_batch.py +++ b/website_sale_aplicoop/tests/test_cron_picking_batch.py @@ -59,47 +59,37 @@ class TestCronPickingBatch(TransactionCase): ) def _create_group_order(self, cutoff_in_past=False, state="open"): - """Create a group order with cutoff date in past or future. + """Create a one-time group order whose cycle ends in past or future. + + One-time group orders derive cutoff_date from end_date, so the cycle + is controlled here through end_date. pickup_date is computed as the + next occurrence of pickup_day after end_date. Args: - cutoff_in_past: If True, cutoff_date will be yesterday (past). - If False, cutoff_date will be tomorrow (future). + cutoff_in_past: If True, end_date (= cutoff_date) is yesterday. + If False, end_date is the day after tomorrow. state: State of the group order """ today = fields.Date.today() - # Create with basic config first - order = self.env["group.order"].create( + if cutoff_in_past: + end_date = today - timedelta(days=1) # Yesterday + else: + end_date = today + timedelta(days=2) # Day after tomorrow + + return self.env["group.order"].create( { "name": f"Test Group Order {'past' if cutoff_in_past else 'future'}", "group_ids": [ (6, 0, [self.consumer_group_1.id, self.consumer_group_2.id]) ], - "period": "once", # One-time order; dates are set explicitly below - "cutoff_day": "0", # Monday + "period": "once", "pickup_day": "2", # Wednesday "state": state, + "end_date": end_date, } ) - # Directly set cutoff_date and pickup_date (bypass computed logic) - if cutoff_in_past: - cutoff_date = today - timedelta(days=1) # Yesterday - pickup_date = today + timedelta(days=1) # Tomorrow - else: - cutoff_date = today + timedelta(days=2) # Day after tomorrow - pickup_date = today + timedelta(days=4) # 4 days from now - - # Write directly to stored computed fields - order.write( - { - "cutoff_date": cutoff_date, - "pickup_date": pickup_date, - } - ) - - return order - def _create_sale_order(self, group_order, partner, consumer_group): """Create a draft sale order linked to the group order.""" return self.env["sale.order"].create( @@ -451,3 +441,273 @@ class TestCronPickingBatch(TransactionCase): "sale", "The order should be confirmed when cron uses non-blocking procurement context", ) + + def test_once_order_closes_after_end_date(self): + """One-time order: full cron confirms, batches and closes it after end_date.""" + group_order = self._create_group_order(cutoff_in_past=True) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + + self.env["group.order"]._cron_update_dates() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "sale", + "Sale order should be confirmed - end date has passed", + ) + self.assertTrue(so.picking_ids, "Sale order should have pickings") + self.assertTrue( + so.picking_ids[0].batch_id, + "Pickings of a one-time order should be batched", + ) + self.assertEqual( + group_order.state, + "closed", + "One-time group order should be closed after its cycle is confirmed", + ) + + def test_once_order_before_end_date_stays_open(self): + """One-time order with a future end_date must not be confirmed nor closed.""" + group_order = self._create_group_order(cutoff_in_past=False) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + end_date_before = group_order.end_date + + group_order._confirm_linked_sale_orders() + group_order._close_one_time_order_if_ended() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "draft", + "Sale order should remain draft - end date not yet passed", + ) + self.assertEqual(group_order.state, "open", "Group order should stay open") + self.assertEqual( + group_order.end_date, + end_date_before, + "end_date must not move while the cycle is still open", + ) + + def test_once_order_without_end_date_is_skipped(self): + """One-time order without end_date has no cutoff and is never auto-confirmed.""" + group_order = self.env["group.order"].create( + { + "name": "Test Group Order permanent once", + "group_ids": [(6, 0, [self.consumer_group_1.id])], + "period": "once", + "pickup_day": "2", + "state": "open", + } + ) + self.assertFalse( + group_order.cutoff_date, + "Non-weekly order without end_date should have no cutoff_date", + ) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + + group_order._confirm_linked_sale_orders() + group_order._close_one_time_order_if_ended() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "draft", + "Sale order should remain draft - no end_date to evaluate", + ) + self.assertEqual(group_order.state, "open", "Group order should stay open") + + def _create_recurring_group_order(self, period, start_date, cutoff_day): + """Create a biweekly/monthly group order anchored at start_date.""" + return self.env["group.order"].create( + { + "name": f"Test {period} Group Order", + "group_ids": [(6, 0, [self.consumer_group_1.id])], + "period": period, + "cutoff_day": cutoff_day, + "pickup_day": "2", # Wednesday + "start_date": start_date, + "state": "open", + } + ) + + def test_biweekly_cutoff_follows_14_day_grid(self): + """Biweekly cutoff lands on the cutoff_day grid anchored at start_date.""" + today = fields.Date.today() + # Anchor 15 days ago on the same weekday as yesterday: the grid is + # [today-15, today-1, today+13, ...] and the compute must pick the + # first occurrence that is today or later. + anchor = today - timedelta(days=15) + cutoff_weekday = (today - timedelta(days=1)).weekday() + group_order = self._create_recurring_group_order( + "biweekly", anchor, str(cutoff_weekday) + ) + + self.assertEqual( + group_order.cutoff_date, + today + timedelta(days=13), + "Biweekly cutoff must be the next 14-day grid point, not next week", + ) + self.assertEqual( + group_order.cutoff_date.weekday(), + cutoff_weekday, + "Biweekly cutoff must fall on the configured cutoff_day", + ) + self.assertGreater( + group_order.pickup_date, + group_order.cutoff_date, + "Pickup must be after the cycle cutoff", + ) + + def test_biweekly_cycle_confirms_and_advances_on_grid(self): + """Biweekly: cron confirms the past cutoff, next cutoff moves +14 days.""" + today = fields.Date.today() + anchor = today - timedelta(days=15) + cutoff_weekday = (today - timedelta(days=1)).weekday() + group_order = self._create_recurring_group_order( + "biweekly", anchor, str(cutoff_weekday) + ) + # Simulate the stored value from the previous cycle (grid point that + # passed yesterday), as the daily cron would have left it. + group_order.write({"cutoff_date": today - timedelta(days=1)}) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + + group_order._confirm_linked_sale_orders() + group_order._close_one_time_order_if_ended() + group_order._compute_cutoff_date() + group_order._compute_pickup_date() + group_order._compute_delivery_date() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "sale", + "Sale order should be confirmed - biweekly cutoff has passed", + ) + self.assertTrue(so.picking_ids, "Sale order should have pickings") + self.assertTrue( + so.picking_ids[0].batch_id, + "Pickings of a biweekly order should be batched", + ) + self.assertEqual( + group_order.state, + "open", + "Biweekly group order should stay open for the next cycle", + ) + self.assertEqual( + group_order.cutoff_date, + today + timedelta(days=13), + "Next biweekly cutoff must be 14 days after the confirmed one", + ) + self.assertFalse( + group_order.end_date, + "end_date must stay empty (permanent recurring order)", + ) + + def test_biweekly_grid_catches_up_after_downtime(self): + """Biweekly grid skips missed cycles and lands today or later.""" + today = fields.Date.today() + # Grid anchored 43 days back: [-43, -29, -15, -1, +13, ...] + anchor = today - timedelta(days=43) + cutoff_weekday = (today - timedelta(days=1)).weekday() + group_order = self._create_recurring_group_order( + "biweekly", anchor, str(cutoff_weekday) + ) + + self.assertEqual( + group_order.cutoff_date, + today + timedelta(days=13), + "Grid must catch up in 14-day steps from the anchor to today", + ) + + def test_monthly_cutoff_on_cutoff_day_and_future(self): + """Monthly cutoff falls on cutoff_day, today or later, within one cycle.""" + today = fields.Date.today() + anchor = today - timedelta(days=40) + cutoff_weekday = today.weekday() + group_order = self._create_recurring_group_order( + "monthly", anchor, str(cutoff_weekday) + ) + + self.assertEqual( + group_order.cutoff_date.weekday(), + cutoff_weekday, + "Monthly cutoff must fall on the configured cutoff_day", + ) + self.assertGreaterEqual( + group_order.cutoff_date, + today, + "Monthly cutoff must be today or in the future", + ) + self.assertLess( + group_order.cutoff_date, + today + timedelta(days=38), + "Monthly cutoff must stay within one monthly cycle from today", + ) + + def test_monthly_cycle_confirms_and_advances(self): + """Monthly: cron confirms the past cutoff, next cutoff keeps the weekday.""" + today = fields.Date.today() + anchor = today - timedelta(days=40) + cutoff_weekday = (today - timedelta(days=1)).weekday() + group_order = self._create_recurring_group_order( + "monthly", anchor, str(cutoff_weekday) + ) + group_order.write({"cutoff_date": today - timedelta(days=1)}) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + + group_order._confirm_linked_sale_orders() + group_order._close_one_time_order_if_ended() + group_order._compute_cutoff_date() + group_order._compute_pickup_date() + group_order._compute_delivery_date() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "sale", + "Sale order should be confirmed - monthly cutoff has passed", + ) + self.assertEqual( + group_order.state, + "open", + "Monthly group order should stay open for the next cycle", + ) + self.assertGreaterEqual( + group_order.cutoff_date, + today, + "Next monthly cutoff must be today or in the future", + ) + self.assertEqual( + group_order.cutoff_date.weekday(), + cutoff_weekday, + "Next monthly cutoff must keep the configured cutoff_day", + ) + + def test_biweekly_without_cutoff_day_is_skipped(self): + """Recurring order without cutoff_day has no cutoff and is never confirmed.""" + today = fields.Date.today() + group_order = self.env["group.order"].create( + { + "name": "Test biweekly without cutoff_day", + "group_ids": [(6, 0, [self.consumer_group_1.id])], + "period": "biweekly", + "pickup_day": "2", + "start_date": today - timedelta(days=15), + "state": "open", + } + ) + self.assertFalse( + group_order.cutoff_date, + "Recurring order without cutoff_day should have no cutoff_date", + ) + so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1) + + group_order._confirm_linked_sale_orders() + + so.invalidate_recordset() + self.assertEqual( + so.state, + "draft", + "Sale order should remain draft - no cutoff_day to evaluate", + ) + self.assertEqual(group_order.state, "open", "Group order should stay open") diff --git a/website_sale_aplicoop/views/group_order_views.xml b/website_sale_aplicoop/views/group_order_views.xml index edf5f4b..be74408 100644 --- a/website_sale_aplicoop/views/group_order_views.xml +++ b/website_sale_aplicoop/views/group_order_views.xml @@ -54,8 +54,8 @@ - - + +