[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

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