diff --git a/stock_picking_batch_custom/models/__init__.py b/stock_picking_batch_custom/models/__init__.py
index 24709d5..c566790 100644
--- a/stock_picking_batch_custom/models/__init__.py
+++ b/stock_picking_batch_custom/models/__init__.py
@@ -1,2 +1,4 @@
from . import stock_move_line # noqa: F401
+from . import stock_backorder_confirmation # noqa: F401
+from . import stock_picking # noqa: F401
from . import stock_picking_batch # noqa: F401
diff --git a/stock_picking_batch_custom/models/stock_backorder_confirmation.py b/stock_picking_batch_custom/models/stock_backorder_confirmation.py
new file mode 100644
index 0000000..ab246c5
--- /dev/null
+++ b/stock_picking_batch_custom/models/stock_backorder_confirmation.py
@@ -0,0 +1,23 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models
+
+
+class StockBackorderConfirmation(models.TransientModel):
+ _inherit = "stock.backorder.confirmation"
+
+ def _check_batch_summary_lines(self):
+ pickings = self.env["stock.picking"].browse(
+ self.env.context.get("button_validate_picking_ids", [])
+ )
+ for batch in pickings.batch_id:
+ batch._check_all_products_collected()
+
+ def process(self):
+ self._check_batch_summary_lines()
+ return super().process()
+
+ def process_cancel_backorder(self):
+ self._check_batch_summary_lines()
+ return super().process_cancel_backorder()
diff --git a/stock_picking_batch_custom/models/stock_move_line.py b/stock_picking_batch_custom/models/stock_move_line.py
index 82bd6d2..a388884 100644
--- a/stock_picking_batch_custom/models/stock_move_line.py
+++ b/stock_picking_batch_custom/models/stock_move_line.py
@@ -1,6 +1,7 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import api
from odoo import fields
from odoo import models
@@ -21,3 +22,18 @@ class StockMoveLine(models.Model):
default=False,
copy=False,
)
+
+ consumer_group_id = fields.Many2one(
+ comodel_name="res.partner",
+ compute="_compute_consumer_group_id",
+ readonly=True,
+ )
+
+ @api.depends("picking_id")
+ def _compute_consumer_group_id(self):
+ for line in self:
+ picking = line.picking_id
+ if picking:
+ line.consumer_group_id = picking.batch_consumer_group_id
+ else:
+ line.consumer_group_id = False
diff --git a/stock_picking_batch_custom/models/stock_picking.py b/stock_picking_batch_custom/models/stock_picking.py
new file mode 100644
index 0000000..f4dc0a2
--- /dev/null
+++ b/stock_picking_batch_custom/models/stock_picking.py
@@ -0,0 +1,57 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import fields
+from odoo import models
+
+
+class StockPicking(models.Model):
+ _inherit = "stock.picking"
+
+ batch_consumer_group_id = fields.Many2one(
+ comodel_name="res.partner",
+ compute="_compute_batch_consumer_group_id",
+ readonly=True,
+ string="Batch Consumer Group",
+ )
+
+ def _compute_batch_consumer_group_id(self):
+ for picking in self:
+ sale = picking.sale_id if "sale_id" in picking._fields else False
+ if sale and "consumer_group_id" in sale._fields:
+ picking.batch_consumer_group_id = sale.consumer_group_id
+ else:
+ picking.batch_consumer_group_id = False
+
+ def _pre_action_done_hook(self):
+ """Run collected checks only after Odoo resolves backorders.
+
+ This keeps the product summary checkbox informative during the initial
+ batch validation click, but still enforces it once the user confirms the
+ actual picking flow that will be processed.
+ """
+
+ res = super()._pre_action_done_hook()
+ if res is not True:
+ return res
+
+ self._check_batch_collected_products()
+ return res
+
+ def _check_batch_collected_products(self):
+ for batch in self.batch_id:
+ batch_pickings = self.filtered(
+ lambda picking, batch=batch: picking.batch_id == batch
+ )
+ processed_product_ids = (
+ batch_pickings.move_line_ids.filtered(
+ lambda line: line.move_id.state not in ("cancel", "done")
+ and line.product_id
+ and line.quantity
+ )
+ .mapped("product_id")
+ .ids
+ )
+ if not processed_product_ids:
+ continue
+ batch._check_all_products_collected(processed_product_ids)
diff --git a/stock_picking_batch_custom/models/stock_picking_batch.py b/stock_picking_batch_custom/models/stock_picking_batch.py
index 798678c..918c92b 100644
--- a/stock_picking_batch_custom/models/stock_picking_batch.py
+++ b/stock_picking_batch_custom/models/stock_picking_batch.py
@@ -140,11 +140,20 @@ class StockPickingBatch(models.Model):
else:
batch.summary_line_ids = [fields.Command.clear()]
- def _check_all_products_collected(self):
- """Ensure all product summary lines are marked as collected before done."""
+ def _check_all_products_collected(self, product_ids=None):
+ """Ensure collected is checked for processed products only.
+
+ The product summary remains informative until Odoo knows which products
+ are actually being validated after the backorder decision.
+ """
+
for batch in self:
not_collected_lines = batch.summary_line_ids.filtered(
- lambda line: not line.is_collected
+ lambda line: (
+ line.qty_done > 0
+ and not line.is_collected
+ and (not product_ids or line.product_id.id in product_ids)
+ )
)
if not not_collected_lines:
continue
@@ -161,10 +170,6 @@ class StockPickingBatch(models.Model):
)
raise UserError(message)
- def action_done(self):
- self._check_all_products_collected()
- return super().action_done()
-
class StockPickingBatchSummaryLine(models.Model):
_name = "stock.picking.batch.summary.line"
diff --git a/stock_picking_batch_custom/tests/test_batch_summary.py b/stock_picking_batch_custom/tests/test_batch_summary.py
index 07f2518..2d96b48 100644
--- a/stock_picking_batch_custom/tests/test_batch_summary.py
+++ b/stock_picking_batch_custom/tests/test_batch_summary.py
@@ -1,9 +1,11 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from unittest.mock import patch
+
from odoo.exceptions import UserError
+from odoo.tests import TransactionCase
from odoo.tests import tagged
-from odoo.tests.common import TransactionCase
@tagged("-at_install", "post_install")
@@ -24,6 +26,13 @@ class TestBatchSummary(TransactionCase):
"uom_po_id": cls.uom_unit.id,
}
)
+ cls.product_2 = cls.env["product.product"].create(
+ {
+ "name": "Test Product 2",
+ "uom_id": cls.uom_unit.id,
+ "uom_po_id": cls.uom_unit.id,
+ }
+ )
cls.batch = cls.env["stock.picking.batch"].create(
{"name": "Batch Test", "picking_type_id": cls.picking_type.id}
@@ -113,6 +122,60 @@ class TestBatchSummary(TransactionCase):
picking.batch_id = batch.id
return batch
+ def _create_partial_batch(self, qty_demanded=2.0, qty_done=1.0, extra_move=False):
+ batch = self.env["stock.picking.batch"].create(
+ {"name": "Batch Partial", "picking_type_id": self.picking_type.id}
+ )
+ batch.picking_type_id.create_backorder = "ask"
+ picking = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.picking_type.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ "batch_id": batch.id,
+ }
+ )
+
+ move = self.env["stock.move"].create(
+ {
+ "name": self.product.name,
+ "product_id": self.product.id,
+ "product_uom_qty": qty_demanded,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ self.env["stock.move.line"].create(
+ {
+ "move_id": move.id,
+ "picking_id": picking.id,
+ "product_id": self.product.id,
+ "product_uom_id": self.uom_unit.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ "quantity": qty_done,
+ }
+ )
+
+ if extra_move:
+ self.env["stock.move"].create(
+ {
+ "name": self.product_2.name,
+ "product_id": self.product_2.id,
+ "product_uom_qty": 1.0,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+
+ batch.action_confirm()
+ batch._compute_summary_line_ids()
+ return batch
+
# Tests
def test_totals_and_pending_with_conversion(self):
"""Totals aggregate per product with UoM conversion and pending."""
@@ -190,14 +253,6 @@ class TestBatchSummary(TransactionCase):
self.assertFalse(batch.summary_line_ids)
# 2. Create pickings with moves (without batch assignment)
- product2 = self.env["product.product"].create(
- {
- "name": "Test Product 2",
- "uom_id": self.uom_unit.id,
- "uom_po_id": self.uom_unit.id,
- }
- )
-
picking_a = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
@@ -226,8 +281,8 @@ class TestBatchSummary(TransactionCase):
)
self.env["stock.move"].create(
{
- "name": product2.name,
- "product_id": product2.id,
+ "name": self.product_2.name,
+ "product_id": self.product_2.id,
"product_uom_qty": 10.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_b.id,
@@ -276,7 +331,7 @@ class TestBatchSummary(TransactionCase):
# Two products expected
products_in_summary = batch.summary_line_ids.mapped("product_id")
self.assertIn(self.product, products_in_summary)
- self.assertIn(product2, products_in_summary)
+ self.assertIn(self.product_2, products_in_summary)
# Check aggregated quantities
line_product1 = batch.summary_line_ids.filtered(
@@ -285,12 +340,12 @@ class TestBatchSummary(TransactionCase):
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
line_product2 = batch.summary_line_ids.filtered(
- lambda line: line.product_id == product2
+ lambda line: line.product_id == self.product_2
)
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
- def test_done_requires_all_summary_lines_collected(self):
- """Batch validation must fail if there are unchecked collected lines."""
+ def test_done_requires_all_summary_lines_collected_without_backorder(self):
+ """Full validation still blocks if processed products are unchecked."""
batch = self._create_batch_with_pickings()
batch.action_confirm()
@@ -302,6 +357,32 @@ class TestBatchSummary(TransactionCase):
with self.assertRaises(UserError):
batch.action_done()
+ def test_partial_validation_waits_for_backorder_wizard_before_blocking(self):
+ """Partial batch validation must open the backorder wizard first."""
+
+ batch = self._create_partial_batch()
+
+ with patch.object(
+ type(self.env["stock.picking"]),
+ "_check_backorder",
+ autospec=True,
+ return_value=batch.picking_ids,
+ ):
+ action = batch.action_done()
+
+ self.assertIsInstance(action, dict)
+ self.assertEqual(action.get("res_model"), "stock.backorder.confirmation")
+
+ def test_partial_validation_checks_only_processed_products(self):
+ """Unchecked lines with no processed quantity must remain informative."""
+
+ batch = self._create_partial_batch(extra_move=True)
+
+ with self.assertRaisesRegex(UserError, self.product.display_name) as err:
+ batch._check_all_products_collected([self.product.id])
+
+ self.assertNotIn(self.product_2.display_name, str(err.exception))
+
def test_check_all_products_collected_passes_when_all_checked(self):
"""Collected validation helper must pass when all lines are checked."""
diff --git a/stock_picking_batch_custom/views/stock_move_line_views.xml b/stock_picking_batch_custom/views/stock_move_line_views.xml
index b857a2c..7fbc218 100644
--- a/stock_picking_batch_custom/views/stock_move_line_views.xml
+++ b/stock_picking_batch_custom/views/stock_move_line_views.xml
@@ -5,11 +5,27 @@
stock.move.line
+
+ hide
+
+
+ hide
+
+
+ hide
+
+
+ hide
+
+
+ hide
+
+
diff --git a/stock_picking_batch_custom/views/stock_picking_batch_views.xml b/stock_picking_batch_custom/views/stock_picking_batch_views.xml
index 43b3ae2..0c6b75e 100644
--- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml
+++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml
@@ -28,4 +28,15 @@
+
+
+ stock.picking.batch.picking.tree.consumer.group
+ stock.picking
+
+
+
+
+
+
+
diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py
index e7c1503..ebdf223 100644
--- a/website_sale_aplicoop/controllers/website_sale.py
+++ b/website_sale_aplicoop/controllers/website_sale.py
@@ -768,6 +768,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id:
raise ValueError("User has no associated partner") from None
+ self._validate_user_group_access(group_order, current_user)
+
# Validate items
items = data.get("items", [])
if not items:
@@ -820,6 +822,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id:
raise ValueError("User has no associated partner")
+ self._validate_user_group_access(group_order, current_user)
+
# Validate items
items = data.get("items", [])
if not items:
@@ -887,6 +891,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id:
raise ValueError("User has no associated partner")
+ self._validate_user_group_access(group_order, current_user)
+
# Validate items
items = data.get("items", [])
if not items:
@@ -921,6 +927,29 @@ class AplicoopWebsiteSale(WebsiteSale):
return False
return bool(value)
+ def _get_effective_delivery_context(self, group_order, is_delivery):
+ """Return effective home delivery flag and commitment date.
+
+ Delivery is effective only when requested by the user and enabled
+ in the group order configuration.
+ """
+ delivery_requested = self._to_bool(is_delivery)
+ delivery_enabled = bool(group_order and group_order.home_delivery)
+ effective_home_delivery = delivery_requested and delivery_enabled
+
+ if delivery_requested and not delivery_enabled:
+ _logger.info(
+ "Delivery requested but disabled in group order %s; using pickup flow",
+ group_order.id if group_order else "N/A",
+ )
+
+ if effective_home_delivery:
+ commitment_date = group_order.delivery_date or group_order.pickup_date
+ else:
+ commitment_date = group_order.pickup_date if group_order else False
+
+ return effective_home_delivery, commitment_date
+
def _process_cart_items(self, items, group_order, pricelist=None):
"""Process cart items and build sale.order line data.
@@ -1009,6 +1038,35 @@ class AplicoopWebsiteSale(WebsiteSale):
# No salesperson found
return False
+ def _get_consumer_group_for_user(self, group_order, current_user):
+ """Return the matching consumer group for the user in this group order."""
+ partner = current_user.partner_id
+ if not partner or not group_order:
+ return False
+
+ user_group_ids = set(partner.group_ids.ids)
+ for consumer_group in group_order.group_ids:
+ if consumer_group.id in user_group_ids:
+ return consumer_group.id
+
+ _logger.warning(
+ "_get_consumer_group_for_user: User %s (%s) is not member of any "
+ "consumer group in order %s. user_groups=%s, order_groups=%s",
+ current_user.name,
+ current_user.id,
+ group_order.id,
+ sorted(user_group_ids),
+ group_order.group_ids.ids,
+ )
+ return False
+
+ def _validate_user_group_access(self, group_order, current_user):
+ """Ensure the user belongs to at least one consumer group of the order."""
+ consumer_group_id = self._get_consumer_group_for_user(group_order, current_user)
+ if not consumer_group_id:
+ raise ValueError("User is not a member of any consumer group in this order")
+ return consumer_group_id
+
def _create_or_update_sale_order(
self,
group_order,
@@ -1022,11 +1080,7 @@ class AplicoopWebsiteSale(WebsiteSale):
Returns the sale.order record.
"""
- # consumer_group_id comes directly from group_order (first/only group)
- # No calculations - data propagates directly from the order context
- consumer_group_id = (
- group_order.group_ids[0].id if group_order.group_ids else False
- )
+ consumer_group_id = self._validate_user_group_access(group_order, current_user)
_logger.info(
"[CONSUMER_GROUP DEBUG] _create_or_update_sale_order: "
@@ -1036,22 +1090,25 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id,
)
- # commitment_date: use provided or calculate from group_order
+ effective_home_delivery, calculated_commitment_date = (
+ self._get_effective_delivery_context(group_order, is_delivery)
+ )
+
+ # Explicit commitment_date wins; otherwise use calculated value
if not commitment_date:
- commitment_date = (
- group_order.delivery_date if is_delivery else group_order.pickup_date
- )
+ commitment_date = calculated_commitment_date
if existing_order:
# Update existing order with new lines and propagate fields
# Use sudo() to avoid permission issues with portal users
existing_order_sudo = existing_order.sudo()
- existing_order_sudo.order_line = sale_order_lines
+ # (5,) clears all existing lines before adding the new ones
+ existing_order_sudo.order_line = [(5,)] + sale_order_lines
if not existing_order_sudo.group_order_id:
existing_order_sudo.group_order_id = group_order.id
existing_order_sudo.pickup_day = group_order.pickup_day
existing_order_sudo.pickup_date = group_order.pickup_date
- existing_order_sudo.home_delivery = is_delivery
+ existing_order_sudo.home_delivery = effective_home_delivery
existing_order_sudo.consumer_group_id = consumer_group_id
if commitment_date:
existing_order_sudo.commitment_date = commitment_date
@@ -1059,7 +1116,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"Updated existing sale.order %d: commitment_date=%s, home_delivery=%s, consumer_group_id=%s",
existing_order.id,
commitment_date,
- is_delivery,
+ effective_home_delivery,
consumer_group_id,
)
return existing_order
@@ -1071,7 +1128,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": group_order.id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
- "home_delivery": is_delivery,
+ "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id,
}
if commitment_date:
@@ -1094,23 +1151,25 @@ class AplicoopWebsiteSale(WebsiteSale):
sale_order.id,
group_order.id,
group_order.pickup_day,
- group_order.home_delivery,
+ effective_home_delivery,
consumer_group_id,
)
return sale_order
def _create_draft_sale_order(
- self, group_order, current_user, sale_order_lines, order_id, pickup_date=None
+ self,
+ group_order,
+ current_user,
+ sale_order_lines,
+ order_id,
+ pickup_date=None,
+ is_delivery=False,
):
"""Create a draft sale.order from prepared lines and propagate group fields.
Returns created sale.order record.
"""
- # consumer_group_id comes directly from group_order (first/only group)
- # No calculations - data propagates directly from the order context
- consumer_group_id = (
- group_order.group_ids[0].id if group_order.group_ids else False
- )
+ consumer_group_id = self._validate_user_group_access(group_order, current_user)
_logger.info(
"[CONSUMER_GROUP DEBUG] _create_draft_sale_order: "
@@ -1120,11 +1179,8 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id,
)
- # commitment_date comes from group_order (delivery_date or pickup_date)
- commitment_date = (
- group_order.delivery_date
- if group_order.home_delivery
- else group_order.pickup_date
+ effective_home_delivery, commitment_date = self._get_effective_delivery_context(
+ group_order, is_delivery
)
order_vals = {
@@ -1134,7 +1190,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
- "home_delivery": group_order.home_delivery,
+ "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id,
"commitment_date": commitment_date,
}
@@ -1374,11 +1430,7 @@ class AplicoopWebsiteSale(WebsiteSale):
All fields (commitment_date, consumer_group_id, etc.) come from group_order.
"""
- # consumer_group_id comes directly from group_order (first/only group)
- # No calculations - data propagates directly from the order context
- consumer_group_id = (
- group_order.group_ids[0].id if group_order.group_ids else False
- )
+ consumer_group_id = self._validate_user_group_access(group_order, current_user)
_logger.info(
"[CONSUMER_GROUP DEBUG] _merge_or_replace_draft: "
@@ -1388,9 +1440,8 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id,
)
- # commitment_date comes directly from selected flow
- commitment_date = (
- group_order.delivery_date if is_delivery else group_order.pickup_date
+ effective_home_delivery, commitment_date = self._get_effective_delivery_context(
+ group_order, is_delivery
)
if existing_drafts:
@@ -1406,7 +1457,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
- "home_delivery": is_delivery,
+ "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id,
"commitment_date": commitment_date,
}
@@ -1420,7 +1471,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
- "home_delivery": is_delivery,
+ "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id,
"commitment_date": commitment_date,
}
@@ -2240,8 +2291,18 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400,
)
+ try:
+ self._validate_user_group_access(group_order, current_user)
+ except ValueError as e:
+ return request.make_response(
+ json.dumps({"error": str(e)}),
+ [("Content-Type", "application/json")],
+ status=403,
+ )
+
items = data.get("items", [])
pickup_date = data.get("pickup_date")
+ is_delivery = self._to_bool(data.get("is_delivery", False))
if not items:
return request.make_response(
json.dumps({"error": "No items in cart"}),
@@ -2262,7 +2323,12 @@ class AplicoopWebsiteSale(WebsiteSale):
)
sale_order = self._create_draft_sale_order(
- group_order, current_user, sale_order_lines, order_id, pickup_date
+ group_order,
+ current_user,
+ sale_order_lines,
+ order_id,
+ pickup_date,
+ is_delivery=is_delivery,
)
_logger.info(
@@ -2629,6 +2695,15 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400,
)
+ try:
+ self._validate_user_group_access(group_order, current_user)
+ except ValueError as e:
+ return request.make_response(
+ json.dumps({"error": str(e)}),
+ [("Content-Type", "application/json")],
+ status=403,
+ )
+
# Get cart items
items = data.get("items", [])
is_delivery = self._to_bool(data.get("is_delivery", False))
@@ -2791,27 +2866,23 @@ class AplicoopWebsiteSale(WebsiteSale):
)
existing_order = None
- # Get pickup date and delivery info from group order
- # If delivery, use delivery_date; otherwise use pickup_date
- commitment_date = None
- if is_delivery and group_order.delivery_date:
- commitment_date = group_order.delivery_date
- elif group_order.pickup_date:
- commitment_date = group_order.pickup_date
+ effective_home_delivery, commitment_date = (
+ self._get_effective_delivery_context(group_order, is_delivery)
+ )
# Create or update sale.order using helper
sale_order = self._create_or_update_sale_order(
group_order,
current_user,
sale_order_lines,
- is_delivery,
+ effective_home_delivery,
commitment_date=commitment_date,
existing_order=existing_order,
)
# Build confirmation message using helper
message_data = self._build_confirmation_message(
- sale_order, group_order, is_delivery
+ sale_order, group_order, effective_home_delivery
)
message = message_data["message"]
pickup_day_name = message_data["pickup_day"]
diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py
index 20b51bd..e0f4250 100644
--- a/website_sale_aplicoop/models/group_order.py
+++ b/website_sale_aplicoop/models/group_order.py
@@ -799,9 +799,45 @@ class GroupOrder(models.Model):
self.delivery_date,
)
- sale_orders.action_confirm()
- # Create picking batches after confirmation
- self._create_picking_batches_for_sale_orders(sale_orders)
+ # Confirm each order in an isolated savepoint so one bad product
+ # route configuration doesn't block all remaining orders.
+ confirmed_sale_orders = SaleOrder.browse()
+ failed_sale_orders = SaleOrder.browse()
+
+ for sale_order in sale_orders:
+ try:
+ with self.env.cr.savepoint():
+ # Do not block sales confirmation due to procurement
+ # route issues. This cron confirms business orders first
+ # and handles stock exceptions operationally.
+ sale_order.with_context(from_orderpoint=True).action_confirm()
+ confirmed_sale_orders |= sale_order
+ except Exception:
+ failed_sale_orders |= sale_order
+ _logger.exception(
+ "Cron: Error confirming sale order %s (%s) for group order %s (%s). "
+ "Order skipped; remaining orders will continue.",
+ sale_order.id,
+ sale_order.name,
+ self.id,
+ self.name,
+ )
+
+ if confirmed_sale_orders:
+ # Create picking batches only for confirmed sale orders
+ self._create_picking_batches_for_sale_orders(confirmed_sale_orders)
+ self._log_missing_procurement_warnings(confirmed_sale_orders)
+
+ if failed_sale_orders:
+ _logger.warning(
+ "Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
+ "failed_sale_order_ids=%s",
+ len(failed_sale_orders),
+ len(sale_orders),
+ self.id,
+ self.name,
+ failed_sale_orders.ids,
+ )
except Exception:
_logger.exception(
"Cron: Error confirming sale orders for group order %s (%s)",
@@ -809,6 +845,42 @@ class GroupOrder(models.Model):
self.name,
)
+ def _log_missing_procurement_warnings(self, sale_orders):
+ """Log warnings for confirmed orders with stockable lines lacking moves.
+
+ This helps operations detect products with missing route/procurement
+ configuration while still allowing sale order confirmation.
+ """
+ self.ensure_one()
+
+ for sale_order in sale_orders:
+ problematic_lines = sale_order.order_line.filtered(
+ lambda line: line.product_id
+ and getattr(line.product_id, "is_storable", False)
+ and line.product_uom_qty > 0
+ and not line.move_ids
+ )
+
+ if not problematic_lines:
+ continue
+
+ product_labels = []
+ for line in problematic_lines:
+ code = line.product_id.default_code or "NO-CODE"
+ name = line.product_id.display_name or line.product_id.name
+ product_labels.append(f"[{code}] {name}")
+
+ _logger.warning(
+ "Cron: Sale order %s (%s) confirmed but %d stockable lines have no stock moves. "
+ "Likely missing replenishment routes/rules. group_order=%s (%s), products=%s",
+ sale_order.id,
+ sale_order.name,
+ len(problematic_lines),
+ self.id,
+ self.name,
+ product_labels,
+ )
+
def _create_picking_batches_for_sale_orders(self, sale_orders):
"""Create stock.picking.batch grouped by consumer_group_id.
diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py
index 2effc65..d423f33 100644
--- a/website_sale_aplicoop/tests/__init__.py
+++ b/website_sale_aplicoop/tests/__init__.py
@@ -10,6 +10,7 @@ from . import test_record_rules # noqa: F401
from . import test_multi_company # noqa: F401
from . import test_save_order_endpoints # noqa: F401
from . import test_date_calculations # noqa: F401
+from . import test_phase3_confirm_eskaera # noqa: F401
from . import test_pricing_with_pricelist # noqa: F401
from . import test_portal_sale_order_creation # noqa: F401
from . import test_cron_picking_batch # noqa: F401
diff --git a/website_sale_aplicoop/tests/test_cron_picking_batch.py b/website_sale_aplicoop/tests/test_cron_picking_batch.py
index d408f8f..ae04036 100644
--- a/website_sale_aplicoop/tests/test_cron_picking_batch.py
+++ b/website_sale_aplicoop/tests/test_cron_picking_batch.py
@@ -2,8 +2,10 @@
# 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
@@ -402,3 +404,54 @@ class TestCronPickingBatch(TransactionCase):
"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",
+ )
diff --git a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py
index 3562c90..f183c0a 100644
--- a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py
+++ b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py
@@ -20,37 +20,108 @@ Includes tests for:
import json
from datetime import date
from datetime import timedelta
-from unittest.mock import Mock
+from types import SimpleNamespace
from unittest.mock import patch
-from odoo import http
from odoo.tests.common import TransactionCase
+from odoo.addons.website_sale_aplicoop.controllers.website_sale import (
+ AplicoopWebsiteSale,
+)
+
+REQUEST_PATCH_TARGET = (
+ "odoo.addons.website_sale_aplicoop.controllers.website_sale.request"
+)
+
+
+def _make_json_response(data, headers=None, status=200):
+ """Build a lightweight HTTP-like response object for controller tests."""
+
+ raw = data.encode("utf-8") if isinstance(data, str) else data
+ return SimpleNamespace(data=raw, status=status, headers=headers or [])
+
+
+def _build_request_mock(env, payload=None, website=None):
+ """Build a request mock compatible with the controller helpers/routes.
+
+ Args:
+ env: Odoo Environment to use as request.env.
+ payload: dict that will be JSON-encoded as request.httprequest.data.
+ website: Optional real website record. When not provided a minimal
+ SimpleNamespace stub is used (sufficient for tests that do not
+ call pricing helpers).
+ """
+ if website is None:
+ website = SimpleNamespace(
+ _get_current_pricelist=lambda: False,
+ show_line_subtotals_tax_selection="tax_excluded",
+ fiscal_position_id=False,
+ company_id=False,
+ )
+ request_mock = SimpleNamespace(
+ env=env,
+ make_response=_make_json_response,
+ website=website,
+ )
+ if payload is not None:
+ request_mock.httprequest = SimpleNamespace(
+ data=json.dumps(payload).encode("utf-8")
+ )
+ return request_mock
+
class TestValidateConfirmJson(TransactionCase):
"""Test _validate_confirm_json() helper method."""
def setUp(self):
super().setUp()
- self.controller = http.request.env["website.sale"].browse([])
- self.user = self.env.ref("base.user_admin")
- self.partner = self.env.ref("base.partner_admin")
+ self.controller = AplicoopWebsiteSale()
+ self.group_1 = self.env["res.partner"].create(
+ {
+ "name": "Consumer Group 1",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.group_2 = self.env["res.partner"].create(
+ {
+ "name": "Consumer Group 2",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.partner = self.env["res.partner"].create(
+ {
+ "name": "Phase 3 Member",
+ "email": "phase3-member@test.com",
+ }
+ )
+ self.group_2.member_ids = [(4, self.partner.id)]
+ self.user = self.env["res.users"].create(
+ {
+ "name": "Phase 3 User",
+ "login": "phase3-user@test.com",
+ "email": "phase3-user@test.com",
+ "partner_id": self.partner.id,
+ }
+ )
# Create test group order
self.group_order = self.env["group.order"].create(
{
"name": "Test Order Phase 3",
"state": "open",
- "collection_date": date.today() + timedelta(days=3),
+ "group_ids": [(6, 0, [self.group_1.id, self.group_2.id])],
+ "start_date": date.today(),
+ "end_date": date.today() + timedelta(days=7),
"cutoff_day": "3", # Thursday
"pickup_day": "5", # Saturday
}
)
- @patch("odoo.http.request")
- def test_validate_confirm_json_success(self, mock_request):
+ def test_validate_confirm_json_success(self):
"""Test successful validation of confirm JSON data."""
- mock_request.env = self.env.with_user(self.user)
+ request_mock = _build_request_mock(self.env(user=self.user))
data = {
"order_id": self.group_order.id,
@@ -58,9 +129,10 @@ class TestValidateConfirmJson(TransactionCase):
"is_delivery": False,
}
- order_id, group_order, current_user, items, is_delivery = (
- self.controller._validate_confirm_json(data)
- )
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ order_id, group_order, current_user, items, is_delivery = (
+ self.controller._validate_confirm_json(data)
+ )
self.assertEqual(order_id, self.group_order.id)
self.assertEqual(group_order.id, self.group_order.id)
@@ -68,52 +140,51 @@ class TestValidateConfirmJson(TransactionCase):
self.assertEqual(len(items), 1)
self.assertFalse(is_delivery)
- @patch("odoo.http.request")
- def test_validate_confirm_json_missing_order_id(self, mock_request):
+ def test_validate_confirm_json_missing_order_id(self):
"""Test validation fails without order_id."""
- mock_request.env = self.env.with_user(self.user)
+ request_mock = _build_request_mock(self.env(user=self.user))
data = {"items": [{"product_id": 1, "quantity": 2}]}
- with self.assertRaises(ValueError) as context:
- self.controller._validate_confirm_json(data)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ with self.assertRaises(ValueError) as context:
+ self.controller._validate_confirm_json(data)
- self.assertIn("Missing order_id", str(context.exception))
+ self.assertIn("order_id is required", str(context.exception))
- @patch("odoo.http.request")
- def test_validate_confirm_json_order_not_exists(self, mock_request):
+ def test_validate_confirm_json_order_not_exists(self):
"""Test validation fails with non-existent order."""
- mock_request.env = self.env.with_user(self.user)
+ request_mock = _build_request_mock(self.env(user=self.user))
data = {
"order_id": 99999, # Non-existent ID
"items": [{"product_id": 1, "quantity": 2}],
}
- with self.assertRaises(ValueError) as context:
- self.controller._validate_confirm_json(data)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ with self.assertRaises(ValueError) as context:
+ self.controller._validate_confirm_json(data)
self.assertIn("Order", str(context.exception))
- @patch("odoo.http.request")
- def test_validate_confirm_json_no_items(self, mock_request):
+ def test_validate_confirm_json_no_items(self):
"""Test validation fails without items in cart."""
- mock_request.env = self.env.with_user(self.user)
+ request_mock = _build_request_mock(self.env(user=self.user))
data = {
"order_id": self.group_order.id,
"items": [],
}
- with self.assertRaises(ValueError) as context:
- self.controller._validate_confirm_json(data)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ with self.assertRaises(ValueError) as context:
+ self.controller._validate_confirm_json(data)
self.assertIn("No items in cart", str(context.exception))
- @patch("odoo.http.request")
- def test_validate_confirm_json_with_delivery_flag(self, mock_request):
+ def test_validate_confirm_json_with_delivery_flag(self):
"""Test validation correctly handles is_delivery flag."""
- mock_request.env = self.env.with_user(self.user)
+ request_mock = _build_request_mock(self.env(user=self.user))
data = {
"order_id": self.group_order.id,
@@ -121,17 +192,165 @@ class TestValidateConfirmJson(TransactionCase):
"is_delivery": True,
}
- _, _, _, _, is_delivery = self.controller._validate_confirm_json(data)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ _, _, _, _, is_delivery = self.controller._validate_confirm_json(data)
self.assertTrue(is_delivery)
+ def test_validate_confirm_json_user_without_matching_group(self):
+ """Validation must fail if user is not in any allowed consumer group."""
+ other_partner = self.env["res.partner"].create(
+ {
+ "name": "Partner without matching group",
+ "email": "nogroup@test.com",
+ }
+ )
+ other_user = self.env["res.users"].create(
+ {
+ "name": "User without matching group",
+ "login": "nogroup-user@test.com",
+ "email": "nogroup-user@test.com",
+ "partner_id": other_partner.id,
+ }
+ )
+ request_mock = _build_request_mock(self.env(user=other_user))
+
+ data = {
+ "order_id": self.group_order.id,
+ "items": [{"product_id": 1, "quantity": 1}],
+ }
+
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ with self.assertRaises(ValueError) as context:
+ self.controller._validate_confirm_json(data)
+
+ self.assertIn(
+ "User is not a member of any consumer group in this order",
+ str(context.exception),
+ )
+
+
+class TestConsumerGroupResolution(TransactionCase):
+ """Tests for consumer group selection in multi-group orders."""
+
+ def setUp(self):
+ super().setUp()
+ self.controller = AplicoopWebsiteSale()
+ self.group_1 = self.env["res.partner"].create(
+ {
+ "name": "Group A",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.group_2 = self.env["res.partner"].create(
+ {
+ "name": "Group B",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.group_3 = self.env["res.partner"].create(
+ {
+ "name": "Group C",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.partner = self.env["res.partner"].create(
+ {
+ "name": "Member B",
+ "email": "memberb@test.com",
+ }
+ )
+ self.group_2.member_ids = [(4, self.partner.id)]
+ self.user = self.env["res.users"].create(
+ {
+ "name": "Member B User",
+ "login": "memberb-user@test.com",
+ "email": "memberb-user@test.com",
+ "partner_id": self.partner.id,
+ }
+ )
+ self.product = self.env["product.product"].create(
+ {
+ "name": "Group Resolution Product",
+ "type": "consu",
+ "list_price": 10.0,
+ }
+ )
+ self.group_order = self.env["group.order"].create(
+ {
+ "name": "Multi-group order",
+ "state": "open",
+ "group_ids": [(6, 0, [self.group_1.id, self.group_2.id])],
+ "start_date": date.today(),
+ "end_date": date.today() + timedelta(days=7),
+ "cutoff_day": "1",
+ "pickup_day": "2",
+ }
+ )
+ self.sale_order_lines = [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "name": "Test line",
+ "product_uom_qty": 1,
+ "price_unit": 10.0,
+ },
+ )
+ ]
+
+ def test_get_consumer_group_for_user_returns_matching_group(self):
+ """Should resolve the user's own consumer group, not the first order group."""
+ consumer_group_id = self.controller._get_consumer_group_for_user(
+ self.group_order, self.user
+ )
+
+ self.assertEqual(consumer_group_id, self.group_2.id)
+
+ def test_get_consumer_group_for_user_uses_first_matching_order_group(self):
+ """If user belongs to multiple valid groups, use the first in group_order."""
+ self.group_1.member_ids = [(4, self.partner.id)]
+ group_order = self.env["group.order"].create(
+ {
+ "name": "Multi-match order",
+ "state": "open",
+ "group_ids": [
+ (6, 0, [self.group_1.id, self.group_2.id, self.group_3.id])
+ ],
+ }
+ )
+
+ consumer_group_id = self.controller._get_consumer_group_for_user(
+ group_order, self.user
+ )
+
+ self.assertEqual(consumer_group_id, self.group_1.id)
+
+ def test_create_or_update_sale_order_assigns_user_consumer_group(self):
+ """Created sale.order must use the consumer group that matches the user."""
+ request_mock = _build_request_mock(self.env(user=self.user))
+
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ sale_order = self.controller._create_or_update_sale_order(
+ self.group_order,
+ self.user,
+ self.sale_order_lines,
+ is_delivery=False,
+ )
+
+ self.assertEqual(sale_order.consumer_group_id.id, self.group_2.id)
+
class TestProcessCartItems(TransactionCase):
"""Test _process_cart_items() helper method."""
def setUp(self):
super().setUp()
- self.controller = http.request.env["website.sale"].browse([])
+ self.controller = AplicoopWebsiteSale()
# Create test products
self.product1 = self.env["product.product"].create(
@@ -148,20 +367,28 @@ class TestProcessCartItems(TransactionCase):
"type": "consu",
}
)
+ self.group = self.env["res.partner"].create(
+ {
+ "name": "Cart Test Group",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
# Create test group order
self.group_order = self.env["group.order"].create(
{
"name": "Test Order for Cart",
"state": "open",
+ "group_ids": [(6, 0, [self.group.id])],
+ "start_date": date.today(),
+ "end_date": date.today() + timedelta(days=7),
}
)
- @patch("odoo.http.request")
- def test_process_cart_items_success(self, mock_request):
+ def test_process_cart_items_success(self):
"""Test successful cart item processing."""
- mock_request.env = self.env
- mock_request.env.lang = "es_ES"
+ request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
items = [
{
@@ -176,7 +403,8 @@ class TestProcessCartItems(TransactionCase):
},
]
- result = self.controller._process_cart_items(items, self.group_order)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ result = self.controller._process_cart_items(items, self.group_order)
self.assertEqual(len(result), 2)
self.assertEqual(result[0][0], 0) # Command (0, 0, vals)
@@ -185,11 +413,9 @@ class TestProcessCartItems(TransactionCase):
self.assertEqual(result[0][2]["product_uom_qty"], 2)
self.assertEqual(result[0][2]["price_unit"], 15.0)
- @patch("odoo.http.request")
- def test_process_cart_items_uses_list_price_fallback(self, mock_request):
+ def test_process_cart_items_uses_list_price_fallback(self):
"""Test cart processing uses list_price when product_price is 0."""
- mock_request.env = self.env
- mock_request.env.lang = "es_ES"
+ request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
items = [
{
@@ -199,17 +425,16 @@ class TestProcessCartItems(TransactionCase):
}
]
- result = self.controller._process_cart_items(items, self.group_order)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ result = self.controller._process_cart_items(items, self.group_order)
self.assertEqual(len(result), 1)
# Should use product.list_price as fallback
self.assertEqual(result[0][2]["price_unit"], self.product1.list_price)
- @patch("odoo.http.request")
- def test_process_cart_items_skips_invalid_product(self, mock_request):
+ def test_process_cart_items_skips_invalid_product(self):
"""Test cart processing skips non-existent products."""
- mock_request.env = self.env
- mock_request.env.lang = "es_ES"
+ request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
items = [
{
@@ -224,30 +449,28 @@ class TestProcessCartItems(TransactionCase):
},
]
- result = self.controller._process_cart_items(items, self.group_order)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ result = self.controller._process_cart_items(items, self.group_order)
# Should only process the valid product
self.assertEqual(len(result), 1)
self.assertEqual(result[0][2]["product_id"], self.product1.id)
- @patch("odoo.http.request")
- def test_process_cart_items_empty_after_filtering(self, mock_request):
+ def test_process_cart_items_empty_after_filtering(self):
"""Test cart processing raises error when no valid items remain."""
- mock_request.env = self.env
- mock_request.env.lang = "es_ES"
+ request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
items = [{"product_id": 99999, "quantity": 1, "product_price": 10.0}]
- with self.assertRaises(ValueError) as context:
- self.controller._process_cart_items(items, self.group_order)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ with self.assertRaises(ValueError) as context:
+ self.controller._process_cart_items(items, self.group_order)
self.assertIn("No valid items", str(context.exception))
- @patch("odoo.http.request")
- def test_process_cart_items_translates_product_name(self, mock_request):
+ def test_process_cart_items_translates_product_name(self):
"""Test cart processing uses translated product names."""
- mock_request.env = self.env
- mock_request.env.lang = "eu_ES" # Basque
+ request_mock = _build_request_mock(self.env.with_context(lang="eu_ES"))
# Add translation for product name
self.env["ir.translation"].create(
@@ -271,7 +494,8 @@ class TestProcessCartItems(TransactionCase):
}
]
- result = self.controller._process_cart_items(items, self.group_order)
+ with patch(REQUEST_PATCH_TARGET, request_mock):
+ result = self.controller._process_cart_items(items, self.group_order)
# Product name should be in Basque context
product_name = result[0][2]["name"]
@@ -284,21 +508,27 @@ class TestBuildConfirmationMessage(TransactionCase):
def setUp(self):
super().setUp()
- self.controller = http.request.env["website.sale"].browse([])
+ self.controller = AplicoopWebsiteSale()
self.user = self.env.ref("base.user_admin")
self.partner = self.env.ref("base.partner_admin")
+ self.group = self.env["res.partner"].create(
+ {
+ "name": "Message Test Group",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
# Create test group order with dates
- pickup_date = date.today() + timedelta(days=5)
- delivery_date = pickup_date + timedelta(days=1)
-
self.group_order = self.env["group.order"].create(
{
"name": "Test Order Messages",
"state": "open",
+ "group_ids": [(6, 0, [self.group.id])],
+ "start_date": date.today(),
+ "end_date": date.today() + timedelta(days=7),
+ "cutoff_day": "3",
"pickup_day": "5", # Saturday (0=Monday)
- "pickup_date": pickup_date,
- "delivery_date": delivery_date,
}
)
@@ -310,7 +540,7 @@ class TestBuildConfirmationMessage(TransactionCase):
}
)
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_pickup(self, mock_request):
"""Test confirmation message for pickup (not delivery)."""
mock_request.env = self.env.with_context(lang="es_ES")
@@ -333,7 +563,7 @@ class TestBuildConfirmationMessage(TransactionCase):
# Should have pickup day index
self.assertEqual(result["pickup_day_index"], 5)
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_delivery(self, mock_request):
"""Test confirmation message for home delivery."""
mock_request.env = self.env.with_context(lang="es_ES")
@@ -353,7 +583,7 @@ class TestBuildConfirmationMessage(TransactionCase):
# pickup_day_index=5 (Saturday), delivery should be 6 (Sunday)
# Note: _get_day_names would need to be mocked for exact day name
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_no_dates(self, mock_request):
"""Test confirmation message when no pickup date is set."""
mock_request.env = self.env.with_context(lang="es_ES")
@@ -363,6 +593,7 @@ class TestBuildConfirmationMessage(TransactionCase):
{
"name": "Order No Dates",
"state": "open",
+ "group_ids": [(6, 0, [self.group.id])],
}
)
@@ -384,7 +615,7 @@ class TestBuildConfirmationMessage(TransactionCase):
# Date fields should be empty
self.assertEqual(result["pickup_date"], "")
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_formats_date(self, mock_request):
"""Test confirmation message formats dates correctly (DD/MM/YYYY)."""
mock_request.env = self.env.with_context(lang="es_ES")
@@ -402,7 +633,7 @@ class TestBuildConfirmationMessage(TransactionCase):
date_pattern = r"\d{2}/\d{2}/\d{4}"
self.assertRegex(pickup_date_str, date_pattern)
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_es(self, mock_request):
"""Test confirmation message in Spanish (es_ES)."""
mock_request.env = self.env.with_context(lang="es_ES")
@@ -416,7 +647,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "¡Gracias!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_eu(self, mock_request):
"""Test confirmation message in Basque (eu_ES)."""
mock_request.env = self.env.with_context(lang="eu_ES")
@@ -429,7 +660,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "Eskerrik asko!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_ca(self, mock_request):
"""Test confirmation message in Catalan (ca_ES)."""
mock_request.env = self.env.with_context(lang="ca_ES")
@@ -442,7 +673,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "Gràcies!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_gl(self, mock_request):
"""Test confirmation message in Galician (gl_ES)."""
mock_request.env = self.env.with_context(lang="gl_ES")
@@ -455,7 +686,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "Grazas!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_pt(self, mock_request):
"""Test confirmation message in Portuguese (pt_PT)."""
mock_request.env = self.env.with_context(lang="pt_PT")
@@ -468,7 +699,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "Obrigado!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_fr(self, mock_request):
"""Test confirmation message in French (fr_FR)."""
mock_request.env = self.env.with_context(lang="fr_FR")
@@ -481,7 +712,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message)
# In real scenario, would check for "Merci!" or similar
- @patch("odoo.http.request")
+ @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_multilang_it(self, mock_request):
"""Test confirmation message in Italian (it_IT)."""
mock_request.env = self.env.with_context(lang="it_IT")
@@ -500,9 +731,29 @@ class TestConfirmEskaera_Integration(TransactionCase):
def setUp(self):
super().setUp()
- self.controller = http.request.env["website.sale"].browse([])
- self.user = self.env.ref("base.user_admin")
- self.partner = self.env.ref("base.partner_admin")
+ self.controller = AplicoopWebsiteSale()
+ self.consumer_group = self.env["res.partner"].create(
+ {
+ "name": "Integration Consumer Group",
+ "is_company": True,
+ "is_group": True,
+ }
+ )
+ self.partner = self.env["res.partner"].create(
+ {
+ "name": "Integration Member",
+ "email": "integration-member@test.com",
+ }
+ )
+ self.consumer_group.member_ids = [(4, self.partner.id)]
+ self.user = self.env["res.users"].create(
+ {
+ "name": "Integration User",
+ "login": "integration-user@test.com",
+ "email": "integration-user@test.com",
+ "partner_id": self.partner.id,
+ }
+ )
# Create test product
self.product = self.env["product.product"].create(
@@ -518,19 +769,20 @@ class TestConfirmEskaera_Integration(TransactionCase):
{
"name": "Integration Test Order",
"state": "open",
+ "group_ids": [(6, 0, [self.consumer_group.id])],
+ "start_date": date.today(),
+ "end_date": date.today() + timedelta(days=7),
+ "cutoff_day": "3",
"pickup_day": "5",
- "pickup_date": date.today() + timedelta(days=5),
+ "home_delivery": True,
}
)
- @patch("odoo.http.request")
- def test_confirm_eskaera_full_flow_pickup(self, mock_request):
- """Test full confirm_eskaera flow for pickup order."""
- mock_request.env = self.env.with_user(self.user)
- mock_request.env.lang = "es_ES"
- mock_request.httprequest = Mock()
+ # Use the real website so pricing helpers can resolve company/fiscal pos
+ self.website = self.env["website"].search([], limit=1)
- # Prepare request data
+ def test_confirm_eskaera_full_flow_pickup(self):
+ """Test full confirm_eskaera flow for pickup order."""
data = {
"order_id": self.group_order.id,
"items": [
@@ -542,11 +794,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
],
"is_delivery": False,
}
+ mock_request = _build_request_mock(
+ self.env(user=self.user, context=dict(self.env.context, lang="es_ES")),
+ data,
+ website=self.website,
+ )
- mock_request.httprequest.data = json.dumps(data).encode("utf-8")
-
- # Call confirm_eskaera
- response = self.controller.confirm_eskaera()
+ with patch(REQUEST_PATCH_TARGET, mock_request):
+ response = self.controller.confirm_eskaera.__wrapped__(self.controller)
# Verify response
self.assertIsNotNone(response)
@@ -565,20 +820,19 @@ class TestConfirmEskaera_Integration(TransactionCase):
self.assertEqual(sale_order.group_order_id.id, self.group_order.id)
self.assertEqual(len(sale_order.order_line), 1)
self.assertEqual(sale_order.order_line[0].product_uom_qty, 3)
+ self.assertFalse(sale_order.home_delivery)
+ self.assertEqual(
+ sale_order.commitment_date.date(),
+ self.group_order.pickup_date,
+ )
- @patch("odoo.http.request")
- def test_confirm_eskaera_full_flow_delivery(self, mock_request):
+ def test_confirm_eskaera_full_flow_delivery(self):
"""Test full confirm_eskaera flow for delivery order."""
- mock_request.env = self.env.with_user(self.user)
- mock_request.env.lang = "es_ES"
- mock_request.httprequest = Mock()
-
# Add delivery_date to group order
self.group_order.delivery_date = self.group_order.pickup_date + timedelta(
days=1
)
- # Prepare request data
data = {
"order_id": self.group_order.id,
"items": [
@@ -590,11 +844,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
],
"is_delivery": True,
}
+ mock_request = _build_request_mock(
+ self.env(user=self.user, context=dict(self.env.context, lang="es_ES")),
+ data,
+ website=self.website,
+ )
- mock_request.httprequest.data = json.dumps(data).encode("utf-8")
-
- # Call confirm_eskaera
- response = self.controller.confirm_eskaera()
+ with patch(REQUEST_PATCH_TARGET, mock_request):
+ response = self.controller.confirm_eskaera.__wrapped__(self.controller)
# Verify response
response_data = json.loads(response.data.decode("utf-8"))
@@ -611,19 +868,17 @@ class TestConfirmEskaera_Integration(TransactionCase):
sale_order.commitment_date.date(), self.group_order.delivery_date
)
- @patch("odoo.http.request")
- def test_confirm_eskaera_updates_existing_draft(self, mock_request):
+ def test_confirm_eskaera_updates_existing_draft(self):
"""Test confirm_eskaera updates existing draft order instead of creating new."""
- mock_request.env = self.env.with_user(self.user)
- mock_request.env.lang = "es_ES"
- mock_request.httprequest = Mock()
-
# Create existing draft order
existing_order = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
"group_order_id": self.group_order.id,
"state": "draft",
+ # pickup_date must match group_order.pickup_date so
+ # _find_recent_draft_order can locate this draft
+ "pickup_date": self.group_order.pickup_date,
"order_line": [
(
0,
@@ -640,7 +895,6 @@ class TestConfirmEskaera_Integration(TransactionCase):
existing_order_id = existing_order.id
- # Prepare new request data
data = {
"order_id": self.group_order.id,
"items": [
@@ -652,11 +906,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
],
"is_delivery": False,
}
+ mock_request = _build_request_mock(
+ self.env(user=self.user, context=dict(self.env.context, lang="es_ES")),
+ data,
+ website=self.website,
+ )
- mock_request.httprequest.data = json.dumps(data).encode("utf-8")
-
- # Call confirm_eskaera
- response = self.controller.confirm_eskaera()
+ with patch(REQUEST_PATCH_TARGET, mock_request):
+ response = self.controller.confirm_eskaera.__wrapped__(self.controller)
response_data = json.loads(response.data.decode("utf-8"))
@@ -668,13 +925,8 @@ class TestConfirmEskaera_Integration(TransactionCase):
self.assertEqual(len(existing_order.order_line), 1)
self.assertEqual(existing_order.order_line[0].product_uom_qty, 5)
- @patch("odoo.http.request")
- def test_confirm_eskaera_ignores_old_period_draft(self, mock_request):
+ def test_confirm_eskaera_ignores_old_period_draft(self):
"""Old draft from previous pickup_date must not be reused."""
- mock_request.env = self.env.with_user(self.user)
- mock_request.env.lang = "es_ES"
- mock_request.httprequest = Mock()
-
old_draft = self.env["sale.order"].create(
{
"partner_id": self.partner.id,
@@ -706,10 +958,15 @@ class TestConfirmEskaera_Integration(TransactionCase):
],
"is_delivery": False,
}
+ mock_request = _build_request_mock(
+ self.env(user=self.user, context=dict(self.env.context, lang="es_ES")),
+ data,
+ website=self.website,
+ )
- mock_request.httprequest.data = json.dumps(data).encode("utf-8")
+ with patch(REQUEST_PATCH_TARGET, mock_request):
+ response = self.controller.confirm_eskaera.__wrapped__(self.controller)
- response = self.controller.confirm_eskaera()
response_data = json.loads(response.data.decode("utf-8"))
self.assertTrue(response_data.get("success"))