- Convertir 4 tests de decorador @patch a context manager 'with patch(...)' para evitar RuntimeError en LocalProxy de Werkzeug - Corregir patrón env(user=..., context=dict(...)) en Odoo 18 (sin .with_context()) - Agregar website real al mock para integración con helpers de pricing (_get_pricing_info) - Añadir pickup_date en fixture de existing_order para que _find_recent_draft_order localice correctamente - BUGFIX: Agregar (5,) a order_line para limpiar líneas previas al actualizar pedido existente Resultado: 0 failed, 0 errors de 4 tests en Docker para TestConfirmEskaera_Integration BREAKING: _create_or_update_sale_order ahora limpia las líneas anteriores con (5,) antes de asignar las nuevas cuando se actualiza un pedido existente. Comportamiento previo (duplicación de líneas) era un bug.
457 lines
16 KiB
Python
457 lines
16 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 group order with cutoff date in past or future.
|
|
|
|
Args:
|
|
cutoff_in_past: If True, cutoff_date will be yesterday (past).
|
|
If False, cutoff_date will be tomorrow (future).
|
|
state: State of the group order
|
|
"""
|
|
today = fields.Date.today()
|
|
|
|
# Create with basic config first
|
|
order = 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
|
|
"pickup_day": "2", # Wednesday
|
|
"state": state,
|
|
}
|
|
)
|
|
|
|
# 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(
|
|
{
|
|
"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_picking_batch_per_consumer_group(self):
|
|
"""Test that cron creates separate picking batches per consumer group."""
|
|
# 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 pickings have batch_id assigned
|
|
for picking in so1.picking_ids:
|
|
self.assertTrue(
|
|
picking.batch_id,
|
|
"Picking from SO1 should be assigned to a batch",
|
|
)
|
|
|
|
for picking in so2.picking_ids:
|
|
self.assertTrue(
|
|
picking.batch_id,
|
|
"Picking from SO2 should be assigned to a batch",
|
|
)
|
|
|
|
# Check that batches are different (one per consumer group)
|
|
batch_1 = so1.picking_ids[0].batch_id
|
|
batch_2 = so2.picking_ids[0].batch_id
|
|
|
|
self.assertNotEqual(
|
|
batch_1.id,
|
|
batch_2.id,
|
|
"Different consumer groups should have different batches",
|
|
)
|
|
|
|
# Check batch descriptions contain consumer group names
|
|
self.assertIn(
|
|
self.consumer_group_1.name,
|
|
batch_1.description,
|
|
"Batch 1 description should include consumer group 1 name",
|
|
)
|
|
self.assertIn(
|
|
self.consumer_group_2.name,
|
|
batch_2.description,
|
|
"Batch 2 description should include consumer group 2 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")
|
|
# scheduled_date should be set (not False/None)
|
|
self.assertTrue(
|
|
batch.scheduled_date,
|
|
"Batch should have a scheduled_date set",
|
|
)
|
|
|
|
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([])
|
|
|
|
# Call second time
|
|
group_order._confirm_linked_sale_orders()
|
|
|
|
batch_second = so.picking_ids[0].batch_id
|
|
batch_count_second = self.env["stock.picking.batch"].search_count([])
|
|
|
|
# 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("Simulated stock route error")
|
|
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",
|
|
)
|