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"))