Compare commits

..

2 commits

Author SHA1 Message Date
snt
2237cba034 [FIX] stock_picking_batch_custom: renombrar etiqueta de grupo de consumo en batch 2026-04-08 17:28:14 +02:00
snt
ce393b6034 [FIX] TestConfirmEskaera_Integration: limpieza de decoradores @patch y corrección de bugs
- Convertir 4 tests de decorador @patch a context manager 'with patch(...)' para evitar RuntimeError en LocalProxy de Werkzeug
- Corregir patrón env(user=..., context=dict(...)) en Odoo 18 (sin .with_context())
- Agregar website real al mock para integración con helpers de pricing (_get_pricing_info)
- Añadir pickup_date en fixture de existing_order para que _find_recent_draft_order localice correctamente
- BUGFIX: Agregar (5,) a order_line para limpiar líneas previas al actualizar pedido existente

Resultado: 0 failed, 0 errors de 4 tests en Docker para TestConfirmEskaera_Integration

BREAKING: _create_or_update_sale_order ahora limpia las líneas anteriores con (5,) antes de asignar las nuevas cuando se actualiza un pedido existente. Comportamiento previo (duplicación de líneas) era un bug.
2026-04-08 17:26:57 +02:00
13 changed files with 857 additions and 192 deletions

View file

@ -1,2 +1,4 @@
from . import stock_move_line # noqa: F401 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 from . import stock_picking_batch # noqa: F401

View file

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

View file

@ -1,6 +1,7 @@
# Copyright 2026 Criptomart # Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import fields from odoo import fields
from odoo import models from odoo import models
@ -21,3 +22,18 @@ class StockMoveLine(models.Model):
default=False, default=False,
copy=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

View file

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

View file

@ -140,11 +140,20 @@ class StockPickingBatch(models.Model):
else: else:
batch.summary_line_ids = [fields.Command.clear()] batch.summary_line_ids = [fields.Command.clear()]
def _check_all_products_collected(self): def _check_all_products_collected(self, product_ids=None):
"""Ensure all product summary lines are marked as collected before done.""" """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: for batch in self:
not_collected_lines = batch.summary_line_ids.filtered( 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: if not not_collected_lines:
continue continue
@ -161,10 +170,6 @@ class StockPickingBatch(models.Model):
) )
raise UserError(message) raise UserError(message)
def action_done(self):
self._check_all_products_collected()
return super().action_done()
class StockPickingBatchSummaryLine(models.Model): class StockPickingBatchSummaryLine(models.Model):
_name = "stock.picking.batch.summary.line" _name = "stock.picking.batch.summary.line"

View file

@ -1,9 +1,11 @@
# Copyright 2026 Criptomart # Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from unittest.mock import patch
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.tests import TransactionCase
from odoo.tests import tagged from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
@ -24,6 +26,13 @@ class TestBatchSummary(TransactionCase):
"uom_po_id": cls.uom_unit.id, "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( cls.batch = cls.env["stock.picking.batch"].create(
{"name": "Batch Test", "picking_type_id": cls.picking_type.id} {"name": "Batch Test", "picking_type_id": cls.picking_type.id}
@ -113,6 +122,60 @@ class TestBatchSummary(TransactionCase):
picking.batch_id = batch.id picking.batch_id = batch.id
return batch 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 # Tests
def test_totals_and_pending_with_conversion(self): def test_totals_and_pending_with_conversion(self):
"""Totals aggregate per product with UoM conversion and pending.""" """Totals aggregate per product with UoM conversion and pending."""
@ -190,14 +253,6 @@ class TestBatchSummary(TransactionCase):
self.assertFalse(batch.summary_line_ids) self.assertFalse(batch.summary_line_ids)
# 2. Create pickings with moves (without batch assignment) # 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_a = self.env["stock.picking"].create(
{ {
"picking_type_id": self.picking_type.id, "picking_type_id": self.picking_type.id,
@ -226,8 +281,8 @@ class TestBatchSummary(TransactionCase):
) )
self.env["stock.move"].create( self.env["stock.move"].create(
{ {
"name": product2.name, "name": self.product_2.name,
"product_id": product2.id, "product_id": self.product_2.id,
"product_uom_qty": 10.0, "product_uom_qty": 10.0,
"product_uom": self.uom_unit.id, "product_uom": self.uom_unit.id,
"picking_id": picking_b.id, "picking_id": picking_b.id,
@ -276,7 +331,7 @@ class TestBatchSummary(TransactionCase):
# Two products expected # Two products expected
products_in_summary = batch.summary_line_ids.mapped("product_id") products_in_summary = batch.summary_line_ids.mapped("product_id")
self.assertIn(self.product, products_in_summary) self.assertIn(self.product, products_in_summary)
self.assertIn(product2, products_in_summary) self.assertIn(self.product_2, products_in_summary)
# Check aggregated quantities # Check aggregated quantities
line_product1 = batch.summary_line_ids.filtered( line_product1 = batch.summary_line_ids.filtered(
@ -285,12 +340,12 @@ class TestBatchSummary(TransactionCase):
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3 self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
line_product2 = batch.summary_line_ids.filtered( 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) self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
def test_done_requires_all_summary_lines_collected(self): def test_done_requires_all_summary_lines_collected_without_backorder(self):
"""Batch validation must fail if there are unchecked collected lines.""" """Full validation still blocks if processed products are unchecked."""
batch = self._create_batch_with_pickings() batch = self._create_batch_with_pickings()
batch.action_confirm() batch.action_confirm()
@ -302,6 +357,32 @@ class TestBatchSummary(TransactionCase):
with self.assertRaises(UserError): with self.assertRaises(UserError):
batch.action_done() 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): def test_check_all_products_collected_passes_when_all_checked(self):
"""Collected validation helper must pass when all lines are checked.""" """Collected validation helper must pass when all lines are checked."""

View file

@ -5,11 +5,27 @@
<field name="model">stock.move.line</field> <field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/> <field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='picking_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='lot_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='lot_name']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='location_dest_id']" position="attributes">
<attribute name="optional">hide</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after"> <xpath expr="//field[@name='product_id']" position="after">
<field name="product_categ_id" optional="hide"/> <field name="product_categ_id" optional="hide"/>
</xpath> </xpath>
<xpath expr="//field[@name='picking_id']" position="after"> <xpath expr="//field[@name='picking_id']" position="after">
<field name="picking_partner_id" optional="hide"/> <field name="picking_partner_id" optional="hide"/>
<field name="consumer_group_id" optional="show"/>
</xpath> </xpath>
<xpath expr="//field[@name='quantity']" position="after"> <xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" optional="show" widget="boolean_toggle"/> <field name="is_collected" optional="show" widget="boolean_toggle"/>

View file

@ -28,4 +28,15 @@
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="view_stock_picking_batch_picking_tree_consumer_group" model="ir.ui.view">
<field name="name">stock.picking.batch.picking.tree.consumer.group</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_view_batch_tree_ref"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="batch_consumer_group_id" optional="show"/>
</xpath>
</field>
</record>
</odoo> </odoo>

View file

@ -768,6 +768,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id: if not current_user.partner_id:
raise ValueError("User has no associated partner") from None raise ValueError("User has no associated partner") from None
self._validate_user_group_access(group_order, current_user)
# Validate items # Validate items
items = data.get("items", []) items = data.get("items", [])
if not items: if not items:
@ -820,6 +822,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id: if not current_user.partner_id:
raise ValueError("User has no associated partner") raise ValueError("User has no associated partner")
self._validate_user_group_access(group_order, current_user)
# Validate items # Validate items
items = data.get("items", []) items = data.get("items", [])
if not items: if not items:
@ -887,6 +891,8 @@ class AplicoopWebsiteSale(WebsiteSale):
if not current_user.partner_id: if not current_user.partner_id:
raise ValueError("User has no associated partner") raise ValueError("User has no associated partner")
self._validate_user_group_access(group_order, current_user)
# Validate items # Validate items
items = data.get("items", []) items = data.get("items", [])
if not items: if not items:
@ -921,6 +927,29 @@ class AplicoopWebsiteSale(WebsiteSale):
return False return False
return bool(value) 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): def _process_cart_items(self, items, group_order, pricelist=None):
"""Process cart items and build sale.order line data. """Process cart items and build sale.order line data.
@ -1009,6 +1038,35 @@ class AplicoopWebsiteSale(WebsiteSale):
# No salesperson found # No salesperson found
return False 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( def _create_or_update_sale_order(
self, self,
group_order, group_order,
@ -1022,11 +1080,7 @@ class AplicoopWebsiteSale(WebsiteSale):
Returns the sale.order record. Returns the sale.order record.
""" """
# consumer_group_id comes directly from group_order (first/only group) consumer_group_id = self._validate_user_group_access(group_order, current_user)
# 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
)
_logger.info( _logger.info(
"[CONSUMER_GROUP DEBUG] _create_or_update_sale_order: " "[CONSUMER_GROUP DEBUG] _create_or_update_sale_order: "
@ -1036,22 +1090,25 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id, 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: if not commitment_date:
commitment_date = ( commitment_date = calculated_commitment_date
group_order.delivery_date if is_delivery else group_order.pickup_date
)
if existing_order: if existing_order:
# Update existing order with new lines and propagate fields # Update existing order with new lines and propagate fields
# Use sudo() to avoid permission issues with portal users # Use sudo() to avoid permission issues with portal users
existing_order_sudo = existing_order.sudo() 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: if not existing_order_sudo.group_order_id:
existing_order_sudo.group_order_id = 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_day = group_order.pickup_day
existing_order_sudo.pickup_date = group_order.pickup_date 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 existing_order_sudo.consumer_group_id = consumer_group_id
if commitment_date: if commitment_date:
existing_order_sudo.commitment_date = 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", "Updated existing sale.order %d: commitment_date=%s, home_delivery=%s, consumer_group_id=%s",
existing_order.id, existing_order.id,
commitment_date, commitment_date,
is_delivery, effective_home_delivery,
consumer_group_id, consumer_group_id,
) )
return existing_order return existing_order
@ -1071,7 +1128,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": group_order.id, "group_order_id": group_order.id,
"pickup_day": group_order.pickup_day, "pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date, "pickup_date": group_order.pickup_date,
"home_delivery": is_delivery, "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id, "consumer_group_id": consumer_group_id,
} }
if commitment_date: if commitment_date:
@ -1094,23 +1151,25 @@ class AplicoopWebsiteSale(WebsiteSale):
sale_order.id, sale_order.id,
group_order.id, group_order.id,
group_order.pickup_day, group_order.pickup_day,
group_order.home_delivery, effective_home_delivery,
consumer_group_id, consumer_group_id,
) )
return sale_order return sale_order
def _create_draft_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. """Create a draft sale.order from prepared lines and propagate group fields.
Returns created sale.order record. Returns created sale.order record.
""" """
# consumer_group_id comes directly from group_order (first/only group) consumer_group_id = self._validate_user_group_access(group_order, current_user)
# 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
)
_logger.info( _logger.info(
"[CONSUMER_GROUP DEBUG] _create_draft_sale_order: " "[CONSUMER_GROUP DEBUG] _create_draft_sale_order: "
@ -1120,11 +1179,8 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id, consumer_group_id,
) )
# commitment_date comes from group_order (delivery_date or pickup_date) effective_home_delivery, commitment_date = self._get_effective_delivery_context(
commitment_date = ( group_order, is_delivery
group_order.delivery_date
if group_order.home_delivery
else group_order.pickup_date
) )
order_vals = { order_vals = {
@ -1134,7 +1190,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id, "group_order_id": order_id,
"pickup_day": group_order.pickup_day, "pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date, "pickup_date": group_order.pickup_date,
"home_delivery": group_order.home_delivery, "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id, "consumer_group_id": consumer_group_id,
"commitment_date": commitment_date, "commitment_date": commitment_date,
} }
@ -1374,11 +1430,7 @@ class AplicoopWebsiteSale(WebsiteSale):
All fields (commitment_date, consumer_group_id, etc.) come from group_order. All fields (commitment_date, consumer_group_id, etc.) come from group_order.
""" """
# consumer_group_id comes directly from group_order (first/only group) consumer_group_id = self._validate_user_group_access(group_order, current_user)
# 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
)
_logger.info( _logger.info(
"[CONSUMER_GROUP DEBUG] _merge_or_replace_draft: " "[CONSUMER_GROUP DEBUG] _merge_or_replace_draft: "
@ -1388,9 +1440,8 @@ class AplicoopWebsiteSale(WebsiteSale):
consumer_group_id, consumer_group_id,
) )
# commitment_date comes directly from selected flow effective_home_delivery, commitment_date = self._get_effective_delivery_context(
commitment_date = ( group_order, is_delivery
group_order.delivery_date if is_delivery else group_order.pickup_date
) )
if existing_drafts: if existing_drafts:
@ -1406,7 +1457,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id, "group_order_id": order_id,
"pickup_day": group_order.pickup_day, "pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date, "pickup_date": group_order.pickup_date,
"home_delivery": is_delivery, "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id, "consumer_group_id": consumer_group_id,
"commitment_date": commitment_date, "commitment_date": commitment_date,
} }
@ -1420,7 +1471,7 @@ class AplicoopWebsiteSale(WebsiteSale):
"group_order_id": order_id, "group_order_id": order_id,
"pickup_day": group_order.pickup_day, "pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date, "pickup_date": group_order.pickup_date,
"home_delivery": is_delivery, "home_delivery": effective_home_delivery,
"consumer_group_id": consumer_group_id, "consumer_group_id": consumer_group_id,
"commitment_date": commitment_date, "commitment_date": commitment_date,
} }
@ -2240,8 +2291,18 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400, 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", []) items = data.get("items", [])
pickup_date = data.get("pickup_date") pickup_date = data.get("pickup_date")
is_delivery = self._to_bool(data.get("is_delivery", False))
if not items: if not items:
return request.make_response( return request.make_response(
json.dumps({"error": "No items in cart"}), json.dumps({"error": "No items in cart"}),
@ -2262,7 +2323,12 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
sale_order = self._create_draft_sale_order( 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( _logger.info(
@ -2629,6 +2695,15 @@ class AplicoopWebsiteSale(WebsiteSale):
status=400, 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 # Get cart items
items = data.get("items", []) items = data.get("items", [])
is_delivery = self._to_bool(data.get("is_delivery", False)) is_delivery = self._to_bool(data.get("is_delivery", False))
@ -2791,27 +2866,23 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
existing_order = None existing_order = None
# Get pickup date and delivery info from group order effective_home_delivery, commitment_date = (
# If delivery, use delivery_date; otherwise use pickup_date self._get_effective_delivery_context(group_order, is_delivery)
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
# Create or update sale.order using helper # Create or update sale.order using helper
sale_order = self._create_or_update_sale_order( sale_order = self._create_or_update_sale_order(
group_order, group_order,
current_user, current_user,
sale_order_lines, sale_order_lines,
is_delivery, effective_home_delivery,
commitment_date=commitment_date, commitment_date=commitment_date,
existing_order=existing_order, existing_order=existing_order,
) )
# Build confirmation message using helper # Build confirmation message using helper
message_data = self._build_confirmation_message( message_data = self._build_confirmation_message(
sale_order, group_order, is_delivery sale_order, group_order, effective_home_delivery
) )
message = message_data["message"] message = message_data["message"]
pickup_day_name = message_data["pickup_day"] pickup_day_name = message_data["pickup_day"]

View file

@ -799,9 +799,45 @@ class GroupOrder(models.Model):
self.delivery_date, self.delivery_date,
) )
sale_orders.action_confirm() # Confirm each order in an isolated savepoint so one bad product
# Create picking batches after confirmation # route configuration doesn't block all remaining orders.
self._create_picking_batches_for_sale_orders(sale_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: except Exception:
_logger.exception( _logger.exception(
"Cron: Error confirming sale orders for group order %s (%s)", "Cron: Error confirming sale orders for group order %s (%s)",
@ -809,6 +845,42 @@ class GroupOrder(models.Model):
self.name, 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): def _create_picking_batches_for_sale_orders(self, sale_orders):
"""Create stock.picking.batch grouped by consumer_group_id. """Create stock.picking.batch grouped by consumer_group_id.

View file

@ -10,6 +10,7 @@ from . import test_record_rules # noqa: F401
from . import test_multi_company # noqa: F401 from . import test_multi_company # noqa: F401
from . import test_save_order_endpoints # noqa: F401 from . import test_save_order_endpoints # noqa: F401
from . import test_date_calculations # 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_pricing_with_pricelist # noqa: F401
from . import test_portal_sale_order_creation # noqa: F401 from . import test_portal_sale_order_creation # noqa: F401
from . import test_cron_picking_batch # noqa: F401 from . import test_cron_picking_batch # noqa: F401

View file

@ -2,8 +2,10 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch
from odoo import fields from odoo import fields
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from odoo.tests.common import tagged from odoo.tests.common import tagged
@ -402,3 +404,54 @@ class TestCronPickingBatch(TransactionCase):
"draft", "draft",
"Sale order should remain draft - group order is closed", "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",
)

View file

@ -20,37 +20,108 @@ Includes tests for:
import json import json
from datetime import date from datetime import date
from datetime import timedelta from datetime import timedelta
from unittest.mock import Mock from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from odoo import http
from odoo.tests.common import TransactionCase 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): class TestValidateConfirmJson(TransactionCase):
"""Test _validate_confirm_json() helper method.""" """Test _validate_confirm_json() helper method."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.controller = http.request.env["website.sale"].browse([]) self.controller = AplicoopWebsiteSale()
self.user = self.env.ref("base.user_admin") self.group_1 = self.env["res.partner"].create(
self.partner = self.env.ref("base.partner_admin") {
"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 # Create test group order
self.group_order = self.env["group.order"].create( self.group_order = self.env["group.order"].create(
{ {
"name": "Test Order Phase 3", "name": "Test Order Phase 3",
"state": "open", "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 "cutoff_day": "3", # Thursday
"pickup_day": "5", # Saturday "pickup_day": "5", # Saturday
} }
) )
@patch("odoo.http.request") def test_validate_confirm_json_success(self):
def test_validate_confirm_json_success(self, mock_request):
"""Test successful validation of confirm JSON data.""" """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 = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
@ -58,9 +129,10 @@ class TestValidateConfirmJson(TransactionCase):
"is_delivery": False, "is_delivery": False,
} }
order_id, group_order, current_user, items, is_delivery = ( with patch(REQUEST_PATCH_TARGET, request_mock):
self.controller._validate_confirm_json(data) 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(order_id, self.group_order.id)
self.assertEqual(group_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.assertEqual(len(items), 1)
self.assertFalse(is_delivery) self.assertFalse(is_delivery)
@patch("odoo.http.request") def test_validate_confirm_json_missing_order_id(self):
def test_validate_confirm_json_missing_order_id(self, mock_request):
"""Test validation fails without order_id.""" """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}]} data = {"items": [{"product_id": 1, "quantity": 2}]}
with self.assertRaises(ValueError) as context: with patch(REQUEST_PATCH_TARGET, request_mock):
self.controller._validate_confirm_json(data) 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):
def test_validate_confirm_json_order_not_exists(self, mock_request):
"""Test validation fails with non-existent order.""" """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 = { data = {
"order_id": 99999, # Non-existent ID "order_id": 99999, # Non-existent ID
"items": [{"product_id": 1, "quantity": 2}], "items": [{"product_id": 1, "quantity": 2}],
} }
with self.assertRaises(ValueError) as context: with patch(REQUEST_PATCH_TARGET, request_mock):
self.controller._validate_confirm_json(data) with self.assertRaises(ValueError) as context:
self.controller._validate_confirm_json(data)
self.assertIn("Order", str(context.exception)) self.assertIn("Order", str(context.exception))
@patch("odoo.http.request") def test_validate_confirm_json_no_items(self):
def test_validate_confirm_json_no_items(self, mock_request):
"""Test validation fails without items in cart.""" """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 = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
"items": [], "items": [],
} }
with self.assertRaises(ValueError) as context: with patch(REQUEST_PATCH_TARGET, request_mock):
self.controller._validate_confirm_json(data) with self.assertRaises(ValueError) as context:
self.controller._validate_confirm_json(data)
self.assertIn("No items in cart", str(context.exception)) self.assertIn("No items in cart", str(context.exception))
@patch("odoo.http.request") def test_validate_confirm_json_with_delivery_flag(self):
def test_validate_confirm_json_with_delivery_flag(self, mock_request):
"""Test validation correctly handles is_delivery flag.""" """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 = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
@ -121,17 +192,165 @@ class TestValidateConfirmJson(TransactionCase):
"is_delivery": True, "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) 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): class TestProcessCartItems(TransactionCase):
"""Test _process_cart_items() helper method.""" """Test _process_cart_items() helper method."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.controller = http.request.env["website.sale"].browse([]) self.controller = AplicoopWebsiteSale()
# Create test products # Create test products
self.product1 = self.env["product.product"].create( self.product1 = self.env["product.product"].create(
@ -148,20 +367,28 @@ class TestProcessCartItems(TransactionCase):
"type": "consu", "type": "consu",
} }
) )
self.group = self.env["res.partner"].create(
{
"name": "Cart Test Group",
"is_company": True,
"is_group": True,
}
)
# Create test group order # Create test group order
self.group_order = self.env["group.order"].create( self.group_order = self.env["group.order"].create(
{ {
"name": "Test Order for Cart", "name": "Test Order for Cart",
"state": "open", "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):
def test_process_cart_items_success(self, mock_request):
"""Test successful cart item processing.""" """Test successful cart item processing."""
mock_request.env = self.env request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
mock_request.env.lang = "es_ES"
items = [ 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(len(result), 2)
self.assertEqual(result[0][0], 0) # Command (0, 0, vals) 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]["product_uom_qty"], 2)
self.assertEqual(result[0][2]["price_unit"], 15.0) self.assertEqual(result[0][2]["price_unit"], 15.0)
@patch("odoo.http.request") def test_process_cart_items_uses_list_price_fallback(self):
def test_process_cart_items_uses_list_price_fallback(self, mock_request):
"""Test cart processing uses list_price when product_price is 0.""" """Test cart processing uses list_price when product_price is 0."""
mock_request.env = self.env request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
mock_request.env.lang = "es_ES"
items = [ 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) self.assertEqual(len(result), 1)
# Should use product.list_price as fallback # Should use product.list_price as fallback
self.assertEqual(result[0][2]["price_unit"], self.product1.list_price) self.assertEqual(result[0][2]["price_unit"], self.product1.list_price)
@patch("odoo.http.request") def test_process_cart_items_skips_invalid_product(self):
def test_process_cart_items_skips_invalid_product(self, mock_request):
"""Test cart processing skips non-existent products.""" """Test cart processing skips non-existent products."""
mock_request.env = self.env request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
mock_request.env.lang = "es_ES"
items = [ 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 # Should only process the valid product
self.assertEqual(len(result), 1) self.assertEqual(len(result), 1)
self.assertEqual(result[0][2]["product_id"], self.product1.id) self.assertEqual(result[0][2]["product_id"], self.product1.id)
@patch("odoo.http.request") def test_process_cart_items_empty_after_filtering(self):
def test_process_cart_items_empty_after_filtering(self, mock_request):
"""Test cart processing raises error when no valid items remain.""" """Test cart processing raises error when no valid items remain."""
mock_request.env = self.env request_mock = _build_request_mock(self.env.with_context(lang="es_ES"))
mock_request.env.lang = "es_ES"
items = [{"product_id": 99999, "quantity": 1, "product_price": 10.0}] items = [{"product_id": 99999, "quantity": 1, "product_price": 10.0}]
with self.assertRaises(ValueError) as context: with patch(REQUEST_PATCH_TARGET, request_mock):
self.controller._process_cart_items(items, self.group_order) with self.assertRaises(ValueError) as context:
self.controller._process_cart_items(items, self.group_order)
self.assertIn("No valid items", str(context.exception)) self.assertIn("No valid items", str(context.exception))
@patch("odoo.http.request") def test_process_cart_items_translates_product_name(self):
def test_process_cart_items_translates_product_name(self, mock_request):
"""Test cart processing uses translated product names.""" """Test cart processing uses translated product names."""
mock_request.env = self.env request_mock = _build_request_mock(self.env.with_context(lang="eu_ES"))
mock_request.env.lang = "eu_ES" # Basque
# Add translation for product name # Add translation for product name
self.env["ir.translation"].create( 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 should be in Basque context
product_name = result[0][2]["name"] product_name = result[0][2]["name"]
@ -284,21 +508,27 @@ class TestBuildConfirmationMessage(TransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.controller = http.request.env["website.sale"].browse([]) self.controller = AplicoopWebsiteSale()
self.user = self.env.ref("base.user_admin") self.user = self.env.ref("base.user_admin")
self.partner = self.env.ref("base.partner_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 # 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( self.group_order = self.env["group.order"].create(
{ {
"name": "Test Order Messages", "name": "Test Order Messages",
"state": "open", "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_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): def test_build_confirmation_message_pickup(self, mock_request):
"""Test confirmation message for pickup (not delivery).""" """Test confirmation message for pickup (not delivery)."""
mock_request.env = self.env.with_context(lang="es_ES") mock_request.env = self.env.with_context(lang="es_ES")
@ -333,7 +563,7 @@ class TestBuildConfirmationMessage(TransactionCase):
# Should have pickup day index # Should have pickup day index
self.assertEqual(result["pickup_day_index"], 5) self.assertEqual(result["pickup_day_index"], 5)
@patch("odoo.http.request") @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_delivery(self, mock_request): def test_build_confirmation_message_delivery(self, mock_request):
"""Test confirmation message for home delivery.""" """Test confirmation message for home delivery."""
mock_request.env = self.env.with_context(lang="es_ES") 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) # pickup_day_index=5 (Saturday), delivery should be 6 (Sunday)
# Note: _get_day_names would need to be mocked for exact day name # 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): def test_build_confirmation_message_no_dates(self, mock_request):
"""Test confirmation message when no pickup date is set.""" """Test confirmation message when no pickup date is set."""
mock_request.env = self.env.with_context(lang="es_ES") mock_request.env = self.env.with_context(lang="es_ES")
@ -363,6 +593,7 @@ class TestBuildConfirmationMessage(TransactionCase):
{ {
"name": "Order No Dates", "name": "Order No Dates",
"state": "open", "state": "open",
"group_ids": [(6, 0, [self.group.id])],
} }
) )
@ -384,7 +615,7 @@ class TestBuildConfirmationMessage(TransactionCase):
# Date fields should be empty # Date fields should be empty
self.assertEqual(result["pickup_date"], "") self.assertEqual(result["pickup_date"], "")
@patch("odoo.http.request") @patch(REQUEST_PATCH_TARGET)
def test_build_confirmation_message_formats_date(self, mock_request): def test_build_confirmation_message_formats_date(self, mock_request):
"""Test confirmation message formats dates correctly (DD/MM/YYYY).""" """Test confirmation message formats dates correctly (DD/MM/YYYY)."""
mock_request.env = self.env.with_context(lang="es_ES") 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}" date_pattern = r"\d{2}/\d{2}/\d{4}"
self.assertRegex(pickup_date_str, date_pattern) 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): def test_build_confirmation_message_multilang_es(self, mock_request):
"""Test confirmation message in Spanish (es_ES).""" """Test confirmation message in Spanish (es_ES)."""
mock_request.env = self.env.with_context(lang="es_ES") mock_request.env = self.env.with_context(lang="es_ES")
@ -416,7 +647,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "¡Gracias!" or similar # 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): def test_build_confirmation_message_multilang_eu(self, mock_request):
"""Test confirmation message in Basque (eu_ES).""" """Test confirmation message in Basque (eu_ES)."""
mock_request.env = self.env.with_context(lang="eu_ES") mock_request.env = self.env.with_context(lang="eu_ES")
@ -429,7 +660,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "Eskerrik asko!" or similar # 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): def test_build_confirmation_message_multilang_ca(self, mock_request):
"""Test confirmation message in Catalan (ca_ES).""" """Test confirmation message in Catalan (ca_ES)."""
mock_request.env = self.env.with_context(lang="ca_ES") mock_request.env = self.env.with_context(lang="ca_ES")
@ -442,7 +673,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "Gràcies!" or similar # 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): def test_build_confirmation_message_multilang_gl(self, mock_request):
"""Test confirmation message in Galician (gl_ES).""" """Test confirmation message in Galician (gl_ES)."""
mock_request.env = self.env.with_context(lang="gl_ES") mock_request.env = self.env.with_context(lang="gl_ES")
@ -455,7 +686,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "Grazas!" or similar # 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): def test_build_confirmation_message_multilang_pt(self, mock_request):
"""Test confirmation message in Portuguese (pt_PT).""" """Test confirmation message in Portuguese (pt_PT)."""
mock_request.env = self.env.with_context(lang="pt_PT") mock_request.env = self.env.with_context(lang="pt_PT")
@ -468,7 +699,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "Obrigado!" or similar # 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): def test_build_confirmation_message_multilang_fr(self, mock_request):
"""Test confirmation message in French (fr_FR).""" """Test confirmation message in French (fr_FR)."""
mock_request.env = self.env.with_context(lang="fr_FR") mock_request.env = self.env.with_context(lang="fr_FR")
@ -481,7 +712,7 @@ class TestBuildConfirmationMessage(TransactionCase):
self.assertIsNotNone(message) self.assertIsNotNone(message)
# In real scenario, would check for "Merci!" or similar # 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): def test_build_confirmation_message_multilang_it(self, mock_request):
"""Test confirmation message in Italian (it_IT).""" """Test confirmation message in Italian (it_IT)."""
mock_request.env = self.env.with_context(lang="it_IT") mock_request.env = self.env.with_context(lang="it_IT")
@ -500,9 +731,29 @@ class TestConfirmEskaera_Integration(TransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.controller = http.request.env["website.sale"].browse([]) self.controller = AplicoopWebsiteSale()
self.user = self.env.ref("base.user_admin") self.consumer_group = self.env["res.partner"].create(
self.partner = self.env.ref("base.partner_admin") {
"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 # Create test product
self.product = self.env["product.product"].create( self.product = self.env["product.product"].create(
@ -518,19 +769,20 @@ class TestConfirmEskaera_Integration(TransactionCase):
{ {
"name": "Integration Test Order", "name": "Integration Test Order",
"state": "open", "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_day": "5",
"pickup_date": date.today() + timedelta(days=5), "home_delivery": True,
} }
) )
@patch("odoo.http.request") # Use the real website so pricing helpers can resolve company/fiscal pos
def test_confirm_eskaera_full_flow_pickup(self, mock_request): self.website = self.env["website"].search([], limit=1)
"""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()
# Prepare request data def test_confirm_eskaera_full_flow_pickup(self):
"""Test full confirm_eskaera flow for pickup order."""
data = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
"items": [ "items": [
@ -542,11 +794,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
], ],
"is_delivery": False, "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)
# Call confirm_eskaera
response = self.controller.confirm_eskaera()
# Verify response # Verify response
self.assertIsNotNone(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(sale_order.group_order_id.id, self.group_order.id)
self.assertEqual(len(sale_order.order_line), 1) self.assertEqual(len(sale_order.order_line), 1)
self.assertEqual(sale_order.order_line[0].product_uom_qty, 3) 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):
def test_confirm_eskaera_full_flow_delivery(self, mock_request):
"""Test full confirm_eskaera flow for delivery order.""" """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 # Add delivery_date to group order
self.group_order.delivery_date = self.group_order.pickup_date + timedelta( self.group_order.delivery_date = self.group_order.pickup_date + timedelta(
days=1 days=1
) )
# Prepare request data
data = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
"items": [ "items": [
@ -590,11 +844,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
], ],
"is_delivery": True, "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") with patch(REQUEST_PATCH_TARGET, mock_request):
response = self.controller.confirm_eskaera.__wrapped__(self.controller)
# Call confirm_eskaera
response = self.controller.confirm_eskaera()
# Verify response # Verify response
response_data = json.loads(response.data.decode("utf-8")) 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 sale_order.commitment_date.date(), self.group_order.delivery_date
) )
@patch("odoo.http.request") def test_confirm_eskaera_updates_existing_draft(self):
def test_confirm_eskaera_updates_existing_draft(self, mock_request):
"""Test confirm_eskaera updates existing draft order instead of creating new.""" """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 # Create existing draft order
existing_order = self.env["sale.order"].create( existing_order = self.env["sale.order"].create(
{ {
"partner_id": self.partner.id, "partner_id": self.partner.id,
"group_order_id": self.group_order.id, "group_order_id": self.group_order.id,
"state": "draft", "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": [ "order_line": [
( (
0, 0,
@ -640,7 +895,6 @@ class TestConfirmEskaera_Integration(TransactionCase):
existing_order_id = existing_order.id existing_order_id = existing_order.id
# Prepare new request data
data = { data = {
"order_id": self.group_order.id, "order_id": self.group_order.id,
"items": [ "items": [
@ -652,11 +906,14 @@ class TestConfirmEskaera_Integration(TransactionCase):
], ],
"is_delivery": False, "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)
# Call confirm_eskaera
response = self.controller.confirm_eskaera()
response_data = json.loads(response.data.decode("utf-8")) 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(len(existing_order.order_line), 1)
self.assertEqual(existing_order.order_line[0].product_uom_qty, 5) self.assertEqual(existing_order.order_line[0].product_uom_qty, 5)
@patch("odoo.http.request") def test_confirm_eskaera_ignores_old_period_draft(self):
def test_confirm_eskaera_ignores_old_period_draft(self, mock_request):
"""Old draft from previous pickup_date must not be reused.""" """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( old_draft = self.env["sale.order"].create(
{ {
"partner_id": self.partner.id, "partner_id": self.partner.id,
@ -706,10 +958,15 @@ class TestConfirmEskaera_Integration(TransactionCase):
], ],
"is_delivery": False, "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")) response_data = json.loads(response.data.decode("utf-8"))
self.assertTrue(response_data.get("success")) self.assertTrue(response_data.get("success"))