[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 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-07-15 14:14:03 +02:00
parent d8cd83bdaf
commit aba22fd230
8 changed files with 510 additions and 74 deletions

View file

@ -1,5 +1,28 @@
# Changelog - Website Sale Aplicoop # 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 ## [18.0.1.9.0] - 2026-05-20
### Added ### Added

View file

@ -3,7 +3,7 @@
{ # noqa: B018 { # noqa: B018
"name": "Website Sale - Aplicoop", "name": "Website Sale - Aplicoop",
"version": "18.0.1.10.2", "version": "18.0.1.11.0",
"category": "Website/Sale", "category": "Website/Sale",
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",

View file

@ -65,27 +65,39 @@ def _get_salesperson_for_order(self, partner):
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): 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. """Return the active-cycle draft sale.order for the partner, or empty.
The active ordering period ends at group_order.cutoff_date and begins The active ordering period ends at group_order.cutoff_date and its length
6 days earlier (weekly cycle). Drafts created outside this window belong depends on the order period: 7 days (weekly), 14 days (biweekly), one
to a previous cycle and must not be reused otherwise stale carts come month (monthly). One-time orders have a single cycle starting at
back when the user re-enters the order page. 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 req = request_obj or request
from datetime import timedelta from datetime import timedelta
from dateutil.relativedelta import relativedelta
if not group_order or not group_order.cutoff_date: if not group_order or not group_order.cutoff_date:
return req.env["sale.order"] return req.env["sale.order"]
period_start = group_order.cutoff_date - timedelta(days=6)
period_end = group_order.cutoff_date 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 = [ domain = [
("partner_id", "=", partner_id), ("partner_id", "=", partner_id),
("group_order_id", "=", group_order.id), ("group_order_id", "=", group_order.id),
("state", "=", "draft"), ("state", "=", "draft"),
("create_date", ">=", f"{period_start} 00:00:00"),
("create_date", "<=", f"{period_end} 23:59:59"), ("create_date", "<=", f"{period_end} 23:59:59"),
] ]
if period_start:
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
return ( return (
req.env["sale.order"].sudo().search(domain, order="create_date desc", limit=1) req.env["sale.order"].sudo().search(domain, order="create_date desc", limit=1)

View file

@ -663,26 +663,39 @@ msgstr "Día en que las compras se detienen"
#. module: website_sale_aplicoop #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day
msgid "" msgid ""
"Day when purchases stop and the consumer group order is locked for this " "Day when purchases stop and the consumer group order is locked for the "
"week." "current cycle."
msgstr "" msgstr ""
"Día en que se detienen las compras y el pedido del grupo de consumidores se " "Día en que se detienen las compras y el pedido de grupo se bloquea para el "
"bloquea para esta semana." "ciclo actual."
#. module: website_sale_aplicoop #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date
msgid "Day when the consumer group order opens for purchases" msgid ""
msgstr "Día en que el pedido del grupo de consumidores se abre para compras" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Day when the order closes (empty = permanent)" msgid ""
msgstr "Día en que el pedido se cierra (vacío = permanente)" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Day when the order opens for purchases" msgid ""
msgstr "Día en que el pedido se abre para compras" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop_products #: 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 #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date
msgid "If empty, the consumer group order is permanent" msgid ""
msgstr "Si está vacío, el pedido del grupo de consumidores es permanente" "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 #. module: website_sale_aplicoop
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image #: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image

View file

@ -661,26 +661,38 @@ msgstr "Erosketak gelditzen diren eguna"
#. module: website_sale_aplicoop #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__cutoff_day
msgid "" msgid ""
"Day when purchases stop and the consumer group order is locked for this " "Day when purchases stop and the consumer group order is locked for the "
"week." "current cycle."
msgstr "" msgstr ""
"Erosketak gelditzen diren eguna eta kontsumitzaileen talde eskaera aste " "Erosketak gelditzen diren eguna, talde-eskaera uneko ziklorako blokeatuz."
"honetarako "
#. module: website_sale_aplicoop #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__start_date
msgid "Day when the consumer group order opens for purchases" msgid ""
msgstr "Erosketak egiteko kontsumitzaileen talde eskaera irekitzeko eguna" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Day when the order closes (empty = permanent)" msgid ""
msgstr "Eskaera itxi den eguna (hutsik = iraunkorra)" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form #: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
msgid "Day when the order opens for purchases" msgid ""
msgstr "Erosketak egiteko eskaera irekitzeko eguna" "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 #. module: website_sale_aplicoop
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop_products #: 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 #. module: website_sale_aplicoop
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date #: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__end_date
msgid "If empty, the consumer group order is permanent" msgid ""
msgstr "Hutsik badago, kontsumitzaileen talde eskaera iraunkorra da" "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 #. module: website_sale_aplicoop
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image #: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__image

View file

@ -4,6 +4,8 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import api from odoo import api
from odoo import fields from odoo import fields
from odoo import models from odoo import models
@ -100,12 +102,17 @@ class GroupOrder(models.Model):
start_date = fields.Date( start_date = fields.Date(
required=False, required=False,
tracking=True, 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( end_date = fields.Date(
required=False, required=False,
tracking=True, 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 === # === Período y días ===
@ -126,7 +133,7 @@ class GroupOrder(models.Model):
selection=_get_day_selection, selection=_get_day_selection,
required=False, required=False,
tracking=True, 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 === # === Home delivery ===
@ -743,24 +750,52 @@ class GroupOrder(models.Model):
reference_date, 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): def _compute_cutoff_date(self):
"""Compute the cutoff date (deadline to place orders before pickup). """Compute the cutoff date (deadline to place orders before pickup).
The cutoff date is the NEXT occurrence of cutoff_day from today. Recurring orders (weekly, biweekly, monthly) use the weekday scheme:
This is when members can no longer place orders. cutoff_date always falls on cutoff_day.
- Weekly: the NEXT occurrence of cutoff_day from today.
Example (as of Monday 2026-02-09): Example (as of Monday 2026-02-09):
- cutoff_day = 6 (Sunday) cutoff_date = 2026-02-15 (next Sunday) - cutoff_day = 6 (Sunday) cutoff_date = 2026-02-15 (next Sunday)
- pickup_day = 1 (Tuesday) pickup_date = 2026-02-17 (Tuesday after cutoff) - 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 from datetime import datetime
_logger.info("_compute_cutoff_date called for %d records", len(self)) _logger.info("_compute_cutoff_date called for %d records", len(self))
for record in self:
if record.cutoff_day:
target_weekday = int(record.cutoff_day)
today = datetime.now().date() today = datetime.now().date()
for record in self:
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)
# Use today as reference if start_date is in the past, otherwise use start_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: if record.start_date and record.start_date < today:
@ -789,7 +824,41 @@ class GroupOrder(models.Model):
days_ahead, days_ahead,
) )
else: 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") @api.depends("pickup_date")
def _compute_delivery_date(self): def _compute_delivery_date(self):
@ -812,12 +881,12 @@ class GroupOrder(models.Model):
# === Onchange Methods === # === Onchange Methods ===
@api.onchange("cutoff_day", "start_date") @api.onchange("cutoff_day", "start_date", "period", "end_date")
def _onchange_cutoff_day(self): def _onchange_cutoff_day(self):
"""Force recompute cutoff_date on UI change for immediate feedback.""" """Force recompute cutoff_date on UI change for immediate feedback."""
self._compute_cutoff_date() 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): def _onchange_pickup_day(self):
"""Force recompute pickup_date on UI change for immediate feedback.""" """Force recompute pickup_date on UI change for immediate feedback."""
self._compute_pickup_date() self._compute_pickup_date()
@ -863,8 +932,10 @@ class GroupOrder(models.Model):
try: try:
# Confirm BEFORE recomputing dates: cutoff_date still points to the # Confirm BEFORE recomputing dates: cutoff_date still points to the
# current cycle's cutoff (today or past), so the check works correctly. # 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._confirm_linked_sale_orders()
order._close_one_time_order_if_ended()
order._compute_cutoff_date() order._compute_cutoff_date()
order._compute_pickup_date() order._compute_pickup_date()
order._compute_delivery_date() order._compute_delivery_date()
@ -892,6 +963,31 @@ class GroupOrder(models.Model):
failed_orders, 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): def _confirm_linked_sale_orders(self):
"""Confirm draft/sent sale orders linked to this group order. """Confirm draft/sent sale orders linked to this group order.
@ -907,13 +1003,17 @@ class GroupOrder(models.Model):
if not self.cutoff_date: if not self.cutoff_date:
_logger.warning( _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.id,
self.name, self.name,
self.state, self.state,
self.period, self.period,
self.cutoff_day, self.cutoff_day,
self.start_date, self.start_date,
self.end_date,
) )
return return

View file

@ -59,47 +59,37 @@ class TestCronPickingBatch(TransactionCase):
) )
def _create_group_order(self, cutoff_in_past=False, state="open"): 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: Args:
cutoff_in_past: If True, cutoff_date will be yesterday (past). cutoff_in_past: If True, end_date (= cutoff_date) is yesterday.
If False, cutoff_date will be tomorrow (future). If False, end_date is the day after tomorrow.
state: State of the group order state: State of the group order
""" """
today = fields.Date.today() today = fields.Date.today()
# Create with basic config first if cutoff_in_past:
order = self.env["group.order"].create( 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'}", "name": f"Test Group Order {'past' if cutoff_in_past else 'future'}",
"group_ids": [ "group_ids": [
(6, 0, [self.consumer_group_1.id, self.consumer_group_2.id]) (6, 0, [self.consumer_group_1.id, self.consumer_group_2.id])
], ],
"period": "once", # One-time order; dates are set explicitly below "period": "once",
"cutoff_day": "0", # Monday
"pickup_day": "2", # Wednesday "pickup_day": "2", # Wednesday
"state": state, "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): def _create_sale_order(self, group_order, partner, consumer_group):
"""Create a draft sale order linked to the group order.""" """Create a draft sale order linked to the group order."""
return self.env["sale.order"].create( return self.env["sale.order"].create(
@ -451,3 +441,273 @@ class TestCronPickingBatch(TransactionCase):
"sale", "sale",
"The order should be confirmed when cron uses non-blocking procurement context", "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")

View file

@ -54,8 +54,8 @@
<field name="sequence" help="Sequence for ordering in website list"/> <field name="sequence" help="Sequence for ordering in website list"/>
<field name="group_ids" widget="many2many_tags" help="Groups that can participate in this order"/> <field name="group_ids" widget="many2many_tags" help="Groups that can participate in this order"/>
<field name="type" help="Type of group order: Regular, Special, or Promotional"/> <field name="type" help="Type of group order: Regular, Special, or Promotional"/>
<field name="start_date" help="Day when the order opens for purchases"/> <field name="start_date" help="Day when the order opens for purchases (anchors biweekly/monthly cycles)"/>
<field name="end_date" help="Day when the order closes (empty = permanent)"/> <field name="end_date" help="Day when the order closes (empty = permanent). One-time orders are confirmed and closed once it has passed"/>
</group> </group>
<group> <group>
<field name="period" help="How often this order repeats"/> <field name="period" help="How often this order repeats"/>