addons-cm/website_sale_aplicoop/tests/test_cron_picking_batch.py
GitHub Copilot aba22fd230 [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>
2026-07-15 16:26:14 +02:00

713 lines
26 KiB
Python

# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import timedelta
from unittest.mock import patch
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
from odoo.tests.common import tagged
@tagged("post_install", "cron_picking_batch")
class TestCronPickingBatch(TransactionCase):
"""Test suite for cron jobs that confirm sale orders and create picking batches."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create consumer groups
cls.consumer_group_1 = cls.env["res.partner"].create(
{
"name": "Consumer Group 1",
"is_company": True,
"is_group": True,
"email": "group1@test.com",
}
)
cls.consumer_group_2 = cls.env["res.partner"].create(
{
"name": "Consumer Group 2",
"is_company": True,
"is_group": True,
"email": "group2@test.com",
}
)
# Create test members
cls.member_1 = cls.env["res.partner"].create(
{
"name": "Member 1",
"email": "member1@test.com",
"parent_id": cls.consumer_group_1.id,
}
)
cls.member_2 = cls.env["res.partner"].create(
{
"name": "Member 2",
"email": "member2@test.com",
"parent_id": cls.consumer_group_2.id,
}
)
# Create a test product (storable to generate pickings)
cls.product = cls.env["product.product"].create(
{
"name": "Test Product",
"is_storable": True, # Odoo 18: storable products generate pickings
"list_price": 10.0,
}
)
def _create_group_order(self, cutoff_in_past=False, state="open"):
"""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, 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()
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",
"pickup_day": "2", # Wednesday
"state": state,
"end_date": end_date,
}
)
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(
{
"partner_id": partner.id,
"group_order_id": group_order.id,
"consumer_group_id": consumer_group.id,
"pickup_date": group_order.pickup_date,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom_qty": 1,
"price_unit": 10.0,
},
)
],
}
)
def test_cron_skips_orders_before_cutoff(self):
"""Test that cron does NOT confirm orders if cutoff date has not passed."""
# Create group order with cutoff in future
group_order = self._create_group_order(cutoff_in_past=False)
# Create draft sale orders
so1 = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
so2 = self._create_sale_order(group_order, self.member_2, self.consumer_group_2)
self.assertEqual(so1.state, "draft")
self.assertEqual(so2.state, "draft")
# Verify cutoff is in future
today = fields.Date.today()
self.assertGreater(
group_order.cutoff_date,
today,
"Cutoff date should be in the future for this test",
)
# Call the confirmation method directly (not full cron to avoid date recalc)
group_order._confirm_linked_sale_orders()
# Sale orders should still be draft (cutoff not passed)
self.assertEqual(
so1.state,
"draft",
"Sale order should remain draft - cutoff date not yet passed",
)
self.assertEqual(
so2.state,
"draft",
"Sale order should remain draft - cutoff date not yet passed",
)
def test_cron_confirms_orders_after_cutoff(self):
"""Test that cron confirms orders when cutoff date has passed."""
# Create group order with cutoff yesterday (past)
group_order = self._create_group_order(cutoff_in_past=True)
# Create draft sale orders
so1 = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
so2 = self._create_sale_order(group_order, self.member_2, self.consumer_group_2)
self.assertEqual(so1.state, "draft")
self.assertEqual(so2.state, "draft")
# Verify cutoff is in past
today = fields.Date.today()
self.assertLess(
group_order.cutoff_date,
today,
"Cutoff date should be in the past for this test",
)
# Call the confirmation method directly
group_order._confirm_linked_sale_orders()
# Refresh records
so1.invalidate_recordset()
so2.invalidate_recordset()
# Sale orders should be confirmed (cutoff passed)
self.assertEqual(
so1.state,
"sale",
"Sale order should be confirmed - cutoff date has passed",
)
self.assertEqual(
so2.state,
"sale",
"Sale order should be confirmed - cutoff date has passed",
)
def test_cron_freezes_pickup_date_on_confirm(self):
"""Confirmed orders must keep the cycle pickup date (no next-week drift)."""
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.assertTrue(
group_order.pickup_date,
"Precondition failed: helper must provide a non-empty pickup_date",
)
# Simulate an inconsistent draft date (e.g. stale or shifted value)
wrong_pickup_date = group_order.pickup_date + timedelta(days=7)
so.write({"pickup_date": wrong_pickup_date})
self.assertEqual(so.state, "draft")
self.assertEqual(so.pickup_date, wrong_pickup_date)
expected_pickup_date = group_order.pickup_date
group_order._confirm_linked_sale_orders()
so.invalidate_recordset()
self.assertEqual(so.state, "sale")
self.assertEqual(
so.pickup_date,
expected_pickup_date,
"Cron should snapshot and preserve the current cycle pickup_date when confirming",
)
def test_cron_creates_single_picking_batch_for_group_order(self):
"""Test that cron creates a single picking batch for the whole group order."""
# Create group order with cutoff yesterday (past)
group_order = self._create_group_order(cutoff_in_past=True)
# Create draft sale orders for different consumer groups
so1 = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
so2 = self._create_sale_order(group_order, self.member_2, self.consumer_group_2)
# Call the confirmation method directly
group_order._confirm_linked_sale_orders()
# Refresh records
so1.invalidate_recordset()
so2.invalidate_recordset()
# Check that pickings were created
self.assertTrue(so1.picking_ids, "Sale order 1 should have pickings")
self.assertTrue(so2.picking_ids, "Sale order 2 should have pickings")
# Check that all pickings share the same batch
batch_1 = so1.picking_ids[0].batch_id
batch_2 = so2.picking_ids[0].batch_id
self.assertEqual(
batch_1.id,
batch_2.id,
"Different consumer groups in the same group order should share one batch",
)
# Check that there is only one batch record created
self.assertEqual(
self.env["stock.picking.batch"].search_count(
[("description", "=", group_order.name)]
),
1,
"Only one batch should be created for a single group order",
)
# Check batch description uses the group order name only
self.assertEqual(
batch_1.description,
group_order.name,
"Batch description should be the group order name",
)
def test_cron_same_consumer_group_same_batch(self):
"""Test that orders from same consumer group go to same batch."""
# Create group order with cutoff yesterday (past)
group_order = self._create_group_order(cutoff_in_past=True)
# Create another member in the same group
member_1b = self.env["res.partner"].create(
{
"name": "Member 1B",
"email": "member1b@test.com",
"parent_id": self.consumer_group_1.id,
}
)
# Create two sale orders from same consumer group
so1 = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
so2 = self._create_sale_order(group_order, member_1b, self.consumer_group_1)
# Call the confirmation method directly
group_order._confirm_linked_sale_orders()
# Refresh records
so1.invalidate_recordset()
so2.invalidate_recordset()
# Check that both pickings are in the same batch
batch_1 = so1.picking_ids[0].batch_id
batch_2 = so2.picking_ids[0].batch_id
self.assertEqual(
batch_1.id,
batch_2.id,
"Same consumer group should have same batch",
)
# Check batch has 2 pickings
self.assertEqual(
len(batch_1.picking_ids),
2,
"Batch should contain 2 pickings from same consumer group",
)
def test_cron_batch_scheduled_date(self):
"""Test that batch has a scheduled_date set."""
# Create group order with cutoff yesterday (past)
group_order = self._create_group_order(cutoff_in_past=True)
# Create a sale order
so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
# Call the confirmation method directly
group_order._confirm_linked_sale_orders()
# Refresh records
so.invalidate_recordset()
# Check batch exists and has scheduled_date
self.assertTrue(so.picking_ids, "Sale order should have pickings")
batch = so.picking_ids[0].batch_id
self.assertTrue(batch, "Picking should have a batch")
self.assertEqual(
batch.scheduled_date.date(),
group_order.pickup_date,
"Batch scheduled_date should be the pickup date (day before delivery)",
)
def test_cron_does_not_duplicate_batches(self):
"""Test that running cron twice does not create duplicate batches."""
# Create group order with cutoff yesterday (past)
group_order = self._create_group_order(cutoff_in_past=True)
# Create a sale order
so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
# Call first time
group_order._confirm_linked_sale_orders()
# Refresh records
so.invalidate_recordset()
self.assertTrue(so.picking_ids, "Sale order should have pickings")
batch_first = so.picking_ids[0].batch_id
batch_count_first = self.env["stock.picking.batch"].search_count(
[("description", "=", group_order.name)]
)
# Call second time
group_order._confirm_linked_sale_orders()
so.invalidate_recordset()
batch_second = so.picking_ids[0].batch_id
batch_count_second = self.env["stock.picking.batch"].search_count(
[("description", "=", group_order.name)]
)
# Should be same batch, no duplicates
self.assertEqual(
batch_first.id,
batch_second.id,
"Batch should not change after second cron execution",
)
self.assertEqual(
batch_count_first,
batch_count_second,
"No new batches should be created on second cron execution",
)
def test_cron_closed_group_order_not_processed(self):
"""Test that closed group orders are not processed by cron."""
# Create group order with cutoff yesterday but state=closed
group_order = self._create_group_order(cutoff_in_past=True, state="closed")
# Create draft sale order
so = self._create_sale_order(group_order, self.member_1, self.consumer_group_1)
# Execute full cron (which only processes draft/open orders)
self.env["group.order"]._cron_update_dates()
# Refresh
so.invalidate_recordset()
# Sale order should still be draft (closed group orders not processed)
self.assertEqual(
so.state,
"draft",
"Sale order should remain draft - group order is closed",
)
def test_cron_confirm_ignores_procurement_usererror(self):
"""Procurement UserError must not block confirmation in cron flow."""
group_order = self._create_group_order(cutoff_in_past=True)
so_ok = self._create_sale_order(
group_order, self.member_1, self.consumer_group_1
)
so_fail = self._create_sale_order(
group_order,
self.member_2,
self.consumer_group_2,
)
so_ok.write({"name": "SO-OK"})
so_fail.write({"name": "SO-FAIL"})
SaleOrderClass = type(self.env["sale.order"])
original_action_confirm = SaleOrderClass.action_confirm
def _patched_action_confirm(recordset):
should_fail = any(so.name == "SO-FAIL" for so in recordset)
if should_fail and not recordset.env.context.get("from_orderpoint"):
raise UserError()
return original_action_confirm(recordset)
with patch.object(SaleOrderClass, "action_confirm", _patched_action_confirm):
group_order._confirm_linked_sale_orders()
so_ok.invalidate_recordset()
so_fail.invalidate_recordset()
self.assertEqual(
so_ok.state,
"sale",
"The valid order must still be confirmed",
)
self.assertTrue(
so_ok.picking_ids,
"The valid order should have pickings created",
)
self.assertTrue(
so_ok.picking_ids[0].batch_id,
"Pickings from valid orders should be batched",
)
self.assertEqual(
so_fail.state,
"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")