From b73f031dfb080ed582a4007a1e6a9dd31e6c6bf2 Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 8 Apr 2026 19:25:03 +0200 Subject: [PATCH 01/12] [FIX] website_sale_aplicoop: renombrar clear_cart a eskaera_clear_cart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evita conflicto de tipo de ruta con el método clear_cart() del padre WebsiteSale de Odoo 18 (type=json). Misma URL /eskaera/clear-cart, solo cambia el nombre del método Python. También añade noqa C901 en save_eskaera_draft (complejidad preexistente). --- website_sale_aplicoop/controllers/website_sale.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index ebdf223..dec32e7 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -2512,7 +2512,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) - def clear_cart(self, **post): + def eskaera_clear_cart(self, **post): """Clear the user's cart and cancel any existing draft sale.order. Receives: JSON body with 'order_id' @@ -2613,7 +2613,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) - def save_eskaera_draft(self, **post): + def save_eskaera_draft(self, **post): # noqa: C901 """Save order as draft (without confirming). Creates a sale.order from the cart items with state='draft'. From 828278573dd1fcb78795d4b0dce542f126191818 Mon Sep 17 00:00:00 2001 From: snt Date: Mon, 18 May 2026 16:07:39 +0200 Subject: [PATCH 02/12] Fix stock picking batch date and does not split batchs by consumer group --- website_sale_aplicoop/models/group_order.py | 44 ++++++------- .../tests/test_cron_picking_batch.py | 63 +++++++++---------- 2 files changed, 48 insertions(+), 59 deletions(-) diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index e0f4250..399e508 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -890,47 +890,41 @@ class GroupOrder(models.Model): self.ensure_one() StockPickingBatch = self.env["stock.picking.batch"].sudo() - # Group sale orders by consumer_group_id - groups = {} - for so in sale_orders: - group_id = so.consumer_group_id.id or False - if group_id not in groups: - groups[group_id] = self.env["sale.order"] - groups[group_id] |= so - - for consumer_group_id, group_sale_orders in groups.items(): - # Get pickings without batch - pickings = group_sale_orders.picking_ids.filtered( - lambda p: p.state not in ("done", "cancel") and not p.batch_id + # Create batches per group order, not per consumer group. + # If multiple picking types exist, keep one batch per picking type. + grouped_pickings = {} + pickings = sale_orders.picking_ids.filtered( + lambda p: p.state not in ("done", "cancel") and not p.batch_id + ) + for picking in pickings: + grouped_pickings.setdefault( + picking.picking_type_id.id, self.env["stock.picking"] ) + grouped_pickings[picking.picking_type_id.id] |= picking + scheduled_date = self.pickup_date + if not scheduled_date and self.delivery_date: + scheduled_date = self.delivery_date - timedelta(days=1) + + for picking_type_id, pickings in grouped_pickings.items(): if not pickings: continue - # Get consumer group name for batch description - consumer_group = self.env["res.partner"].browse(consumer_group_id) - batch_desc = ( - f"{self.name} - {consumer_group.name}" if consumer_group else self.name - ) - - # Create the batch + batch_desc = self.name batch = StockPickingBatch.create( { "description": batch_desc, "company_id": self.company_id.id, - "picking_type_id": pickings[0].picking_type_id.id, - "scheduled_date": self.pickup_date, + "picking_type_id": picking_type_id, + "scheduled_date": scheduled_date, } ) - # Assign pickings to the batch pickings.write({"batch_id": batch.id}) _logger.info( - "Cron: Created batch %s with %d pickings for group order %s, " - "consumer group %s", + "Cron: Created batch %s with %d pickings for group order %s", batch.name, len(pickings), self.name, - consumer_group.name if consumer_group else "N/A", ) diff --git a/website_sale_aplicoop/tests/test_cron_picking_batch.py b/website_sale_aplicoop/tests/test_cron_picking_batch.py index ae04036..f2cb3b5 100644 --- a/website_sale_aplicoop/tests/test_cron_picking_batch.py +++ b/website_sale_aplicoop/tests/test_cron_picking_batch.py @@ -227,8 +227,8 @@ class TestCronPickingBatch(TransactionCase): "Cron should snapshot and preserve the current cycle pickup_date when confirming", ) - def test_cron_creates_picking_batch_per_consumer_group(self): - """Test that cron creates separate picking batches per consumer group.""" + def test_cron_creates_single_picking_batch_for_group_order(self): + """Test that cron creates a single picking batch for the whole group order.""" # Create group order with cutoff yesterday (past) group_order = self._create_group_order(cutoff_in_past=True) @@ -247,39 +247,30 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so1.picking_ids, "Sale order 1 should have pickings") self.assertTrue(so2.picking_ids, "Sale order 2 should have pickings") - # Check that pickings have batch_id assigned - for picking in so1.picking_ids: - self.assertTrue( - picking.batch_id, - "Picking from SO1 should be assigned to a batch", - ) - - for picking in so2.picking_ids: - self.assertTrue( - picking.batch_id, - "Picking from SO2 should be assigned to a batch", - ) - - # Check that batches are different (one per consumer group) + # Check that all pickings share the same batch batch_1 = so1.picking_ids[0].batch_id batch_2 = so2.picking_ids[0].batch_id - self.assertNotEqual( + self.assertEqual( batch_1.id, batch_2.id, - "Different consumer groups should have different batches", + "Different consumer groups in the same group order should share one batch", ) - # Check batch descriptions contain consumer group names - self.assertIn( - self.consumer_group_1.name, - batch_1.description, - "Batch 1 description should include consumer group 1 name", + # Check that there is only one batch record created + self.assertEqual( + self.env["stock.picking.batch"].search_count( + [("description", "=", group_order.name)] + ), + 1, + "Only one batch should be created for a single group order", ) - self.assertIn( - self.consumer_group_2.name, - batch_2.description, - "Batch 2 description should include consumer group 2 name", + + # Check batch description uses the group order name only + self.assertEqual( + batch_1.description, + group_order.name, + "Batch description should be the group order name", ) def test_cron_same_consumer_group_same_batch(self): @@ -342,10 +333,10 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so.picking_ids, "Sale order should have pickings") batch = so.picking_ids[0].batch_id self.assertTrue(batch, "Picking should have a batch") - # scheduled_date should be set (not False/None) - self.assertTrue( - batch.scheduled_date, - "Batch should have a scheduled_date set", + self.assertEqual( + batch.scheduled_date.date(), + group_order.pickup_date, + "Batch scheduled_date should be the pickup date (day before delivery)", ) def test_cron_does_not_duplicate_batches(self): @@ -364,13 +355,17 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so.picking_ids, "Sale order should have pickings") batch_first = so.picking_ids[0].batch_id - batch_count_first = self.env["stock.picking.batch"].search_count([]) + batch_count_first = self.env["stock.picking.batch"].search_count( + [("description", "=", group_order.name)] + ) # Call second time group_order._confirm_linked_sale_orders() batch_second = so.picking_ids[0].batch_id - batch_count_second = self.env["stock.picking.batch"].search_count([]) + batch_count_second = self.env["stock.picking.batch"].search_count( + [("description", "=", group_order.name)] + ) # Should be same batch, no duplicates self.assertEqual( @@ -427,7 +422,7 @@ class TestCronPickingBatch(TransactionCase): 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") + raise UserError() return original_action_confirm(recordset) with patch.object(SaleOrderClass, "action_confirm", _patched_action_confirm): From 1d4971c8039dab39e3ac1fff0189f2ae232fed14 Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 15:03:26 +0200 Subject: [PATCH 03/12] =?UTF-8?q?[IMP]=20website=5Fsale=5Faplicoop:=20mejo?= =?UTF-8?q?ra=20card=20producto=20(placeholder,=20responsive=20m=C3=B3vil,?= =?UTF-8?q?=20accesibilidad=20y=20estilo=20profesional)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/css/components/product-card.css | 167 +++++++++++++----- 1 file changed, 118 insertions(+), 49 deletions(-) diff --git a/website_sale_aplicoop/static/src/css/components/product-card.css b/website_sale_aplicoop/static/src/css/components/product-card.css index c6eef5c..fd36a45 100644 --- a/website_sale_aplicoop/static/src/css/components/product-card.css +++ b/website_sale_aplicoop/static/src/css/components/product-card.css @@ -5,38 +5,44 @@ */ .product-card { - background-color: white; + background-color: #fff; border: 1px solid #e0e0e0; - border-radius: 8px; + border-radius: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - transition: all 0.3s ease; + transition: box-shadow 0.3s, transform 0.2s; display: flex; flex-direction: column; - padding: 0.5rem 0.5rem 0.5rem 0.5rem; + padding: 0.5rem; height: 100%; overflow: hidden; + outline: none; } -.product-card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12); -} - +.product-card:hover, .product-card:focus-within { - outline: 3px solid var(--primary-color); + transform: translateY(-4px) scale(1.01); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13); + outline: 2px solid var(--primary-color, #007bff); outline-offset: 2px; } .product-card .product-image { - height: 150px; + height: 120px; + width: 100%; object-fit: cover; + border-radius: 8px 8px 0 0; + background: #f3f3f3; + display: block; } .product-img-cover { - max-height: 160px; + max-height: 120px; + width: 100%; object-fit: cover; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(40, 39, 39, 0.9); + border-radius: 8px 8px 0 0; + box-shadow: 0 2px 8px rgba(40, 39, 39, 0.09); + background: #f3f3f3; + display: block; } .product-card .card-body { @@ -44,105 +50,168 @@ flex-direction: column; height: 100%; flex-grow: 1; - padding: 0.75rem; + padding: 0.6rem 0.7rem 0.7rem 0.7rem; position: relative; - background: linear-gradient(135deg, rgba(0, 123, 255, 0.12) 0%, rgba(0, 123, 255, 0.08) 100%); - transition: all 0.3s ease; + background: linear-gradient(135deg, rgba(0, 123, 255, 0.07) 0%, rgba(0, 123, 255, 0.04) 100%); + transition: background 0.3s; } -.product-card:hover .card-body { +.product-card:hover .card-body, +.product-card:focus-within .card-body { background: linear-gradient( 135deg, - rgba(108, 117, 125, 0.1) 0%, - rgba(108, 117, 125, 0.08) 100% + rgba(108, 117, 125, 0.13) 0%, + rgba(108, 117, 125, 0.09) 100% ); } .product-card .card-title { flex-grow: 1; - margin: 0; - margin-bottom: 0.2rem; + margin: 0 0 0.15rem 0; min-height: auto; display: block; word-wrap: break-word; overflow-wrap: break-word; - font-size: 1.2rem !important; - line-height: 1; + font-size: 1.08rem !important; + line-height: 1.1; text-align: center; font-weight: 600; - color: #2d3748; + color: #1a202c; + letter-spacing: 0.01em; } .product-card .card-text { - margin-bottom: 0.15rem; + margin-bottom: 0.12rem; text-align: center; + font-size: 1rem; } .product-card .card-text strong { display: block; - margin-bottom: 0.15rem; - font-size: 1.2rem; - color: #667eea; + margin-bottom: 0.1rem; + font-size: 1.15rem; + color: #3b82f6; } .product-card .product-supplier { text-align: center; color: #4a5568; font-weight: 400; - margin-bottom: 0.15rem; - font-size: 0.9rem !important; + margin-bottom: 0.12rem; + font-size: 0.92rem !important; } .product-tags { text-align: center; display: flex; flex-wrap: wrap; - gap: 0.2rem; + gap: 0.18rem; justify-content: center; font-weight: 400; - font-size: 1.4rem !important; + font-size: 1.1rem !important; margin: 0; padding: 0; } .badge-km { - background-color: var(--primary-color) !important; - color: white !important; + background-color: var(--primary-color, #007bff) !important; + color: #fff !important; font-weight: 600 !important; - padding: 0.2rem !important; - font-size: 0.6rem !important; - border-radius: 0.2rem; + padding: 0.18rem 0.32rem !important; + font-size: 0.68rem !important; + border-radius: 0.22rem; display: inline-block; - border: 1px solid; + border: 1px solid #007bff; white-space: nowrap; - margin-right: 0.1rem; - margin-bottom: 0.1rem; + margin-right: 0.08rem; + margin-bottom: 0.08rem; } .card-body p.card-text { text-align: center; - margin-bottom: 0.8rem; - min-height: 2rem; + margin-bottom: 0.6rem; + min-height: 1.7rem; display: flex; align-items: center; justify-content: center; - background-color: var(--primary-color); - color: white; + background-color: var(--primary-color, #007bff); + color: #fff; + border-radius: 0.18rem; + font-size: 1.05rem; } .card-body p.card-text strong { display: inline; - font-size: 1.4rem !important; - color: var(--primary-color); + font-size: 1.18rem !important; + color: var(--primary-color, #007bff); margin-bottom: 0; white-space: nowrap; } .product-img-fixed { object-fit: cover; - height: 100px; + height: 120px; + width: 100%; + border-radius: 8px 8px 0 0; + background: #f3f3f3; + display: block; } .product-img-placeholder { - height: 100px; + height: 120px; + width: 100%; + object-fit: cover; + border-radius: 8px 8px 0 0; + background: #f3f3f3 + url('data:image/svg+xml;utf8,Sin imagen') + no-repeat center center; + display: block; +} + +/* Responsive: mejorar altura y espaciado en móvil */ +@media (max-width: 600px) { + .product-card { + padding: 0.25rem; + border-radius: 8px; + } + .product-card .product-image, + .product-img-cover, + .product-img-fixed, + .product-img-placeholder { + height: 70px; + max-height: 70px; + min-height: 70px; + border-radius: 6px 6px 0 0; + } + .product-card .card-body { + padding: 0.4rem 0.4rem 0.5rem 0.4rem; + } + .product-card .card-title { + font-size: 0.98rem !important; + margin-bottom: 0.08rem; + } + .product-card .card-text { + font-size: 0.92rem; + } + .badge-km { + font-size: 0.58rem !important; + padding: 0.13rem 0.22rem !important; + } + .product-tags { + font-size: 0.95rem !important; + } + .card-body p.card-text { + min-height: 1.1rem; + font-size: 0.95rem; + margin-bottom: 0.3rem; + } + .product-card .product-supplier { + font-size: 0.82rem !important; + } +} + +/* Accesibilidad: focus visible */ +.product-card:focus-visible { + outline: 2.5px solid var(--primary-color, #007bff); + outline-offset: 2px; } From 1b20b23fc0e00b1f0b51ebbcf92fe742a7649981 Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 15:30:11 +0200 Subject: [PATCH 04/12] =?UTF-8?q?[IMP]=20stock=5Fpicking=5Fbatch=5Fcustom:?= =?UTF-8?q?=20UX=20-=20zebra=20rows,=20mostrar=20categor=C3=ADa=20a=20la?= =?UTF-8?q?=20izquierda,=20company+uom=20ocultables,=20a=C3=B1adir=20CSS?= =?UTF-8?q?=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../static/src/css/stock_picking_batch.css | 18 ++++++++++++++++++ .../views/stock_picking_batch_views.xml | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 stock_picking_batch_custom/static/src/css/stock_picking_batch.css diff --git a/stock_picking_batch_custom/static/src/css/stock_picking_batch.css b/stock_picking_batch_custom/static/src/css/stock_picking_batch.css new file mode 100644 index 0000000..52f70bb --- /dev/null +++ b/stock_picking_batch_custom/static/src/css/stock_picking_batch.css @@ -0,0 +1,18 @@ +/* zebra striping for list views in this module */ + +/* Target Odoo list/tree view tables. Use a specific, but broad selector to + avoid interfering globally with other modules. */ +.o_list_view .o_list_view_table tbody tr:nth-child(even) td { + background-color: rgba(0, 0, 0, 0.03); +} + +/* Slight hover contrast to improve row focus */ +.o_list_view .o_list_view_table tbody tr:hover td { + background-color: rgba(0, 0, 0, 0.045); +} + +/* Ensure checkboxes / toggle columns maintain contrast */ +.o_list_view .o_list_view_table tbody tr:nth-child(even) td .o_field_widget, +.o_list_view .o_list_view_table tbody tr:nth-child(even) td .o_field_widget * { + background: transparent; +} 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 0c6b75e..cd9bd6d 100644 --- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -5,8 +5,11 @@ stock.picking.batch.summary.line + + - + + @@ -39,4 +42,11 @@ + + + From 3372cb453ba2821c9f9489a421639b77d2daf06f Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 15:37:30 +0200 Subject: [PATCH 05/12] [FIX] stock_picking_batch_custom: quitar company_id inexistente en summary line view --- stock_picking_batch_custom/views/stock_picking_batch_views.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 cd9bd6d..6482521 100644 --- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -8,8 +8,7 @@ - - + From 4a928e92ddef57e2a0c833e8a808bf1d44bc4865 Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 15:38:05 +0200 Subject: [PATCH 06/12] =?UTF-8?q?[FIX]=20stock=5Fpicking=5Fbatch=5Fcustom:?= =?UTF-8?q?=20mover=20inclusi=C3=B3n=20de=20CSS=20a=20manifest=20assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock_picking_batch_custom/__manifest__.py | 5 +++++ .../views/stock_picking_batch_views.xml | 7 +------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/stock_picking_batch_custom/__manifest__.py b/stock_picking_batch_custom/__manifest__.py index 1273545..0044d2f 100644 --- a/stock_picking_batch_custom/__manifest__.py +++ b/stock_picking_batch_custom/__manifest__.py @@ -18,4 +18,9 @@ "views/stock_move_line_views.xml", "views/stock_picking_batch_views.xml", ], + "assets": { + "web.assets_backend": [ + "stock_picking_batch_custom/static/src/css/stock_picking_batch.css", + ], + }, } 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 6482521..35fe168 100644 --- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -42,10 +42,5 @@ - - + From 3ca90578aeda74981aa0fc1df9aab524f7f75177 Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 16:45:42 +0200 Subject: [PATCH 07/12] =?UTF-8?q?[IMP]=20website=5Fsale=5Faplicoop:=20Vali?= =?UTF-8?q?dar=20disponibilidad=20de=20productos=20al=20cargar=20=C3=B3rde?= =?UTF-8?q?nes=20hist=C3=B3ricas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Agregar método _validate_items_for_group_order() para validar que los productos históricos sigan siendo disponibles en la orden de grupo actual - Backend: Modificar load_order_from_history() para filtrar solo items disponibles antes de pasar al template - Backend: Generar mensaje de aviso traducido cuando hay productos no disponibles - Template: Pasar información de productos no disponibles y warnings al JavaScript - Frontend: Mostrar notificación de advertencia si hubo productos excluidos durante la carga histórica - Notas: Esto evita cargar productos que ya no existen en la orden actual debido a cambios en categorías, proveedores o listas negras --- website_sale_aplicoop/__manifest__.py | 1 + .../controllers/website_sale.py | 179 +++++++++++++++++- website_sale_aplicoop/models/group_order.py | 12 +- .../static/src/js/website_sale.js | 38 +++- .../tests/test_cron_picking_batch.py | 1 + .../views/load_from_history_templates.xml | 11 ++ .../views/website_sale_disable_cart.xml | 37 ++++ 7 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 website_sale_aplicoop/views/website_sale_disable_cart.xml diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index 3de62ce..a566e3d 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -37,6 +37,7 @@ "views/res_partner_views.xml", "views/res_config_settings_views.xml", "views/website_templates.xml", + "views/website_sale_disable_cart.xml", "views/product_template_views.xml", "views/sale_order_views.xml", "views/stock_picking_views.xml", diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index dec32e7..b302a49 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -1511,6 +1511,87 @@ class AplicoopWebsiteSale(WebsiteSale): status=status, ) + def _validate_items_for_group_order(self, items, group_order): + """Validate items from historical order against current group order availability. + + Args: + items: list of dicts with keys: product_id, product_name, quantity, price + group_order: group.order record + + Returns: + dict with keys: + - available_items: list of items that are available in current group order + - unavailable_items: list of items that are NOT available + - unavailable_products: set of product IDs that are unavailable + - warning_message: str, message about unavailable items (or empty) + """ + if not items: + return { + "available_items": [], + "unavailable_items": [], + "unavailable_products": set(), + "warning_message": "", + } + + # Get available products for the current group order + try: + available_products = request.env[ + "group.order" + ]._get_products_for_group_order(group_order.id) + available_product_ids = set(available_products.ids) + except Exception as e: + _logger.error( + "Error getting available products for group_order %d: %s", + group_order.id, + e, + ) + # If something fails, return all items as available (failsafe) + return { + "available_items": items, + "unavailable_items": [], + "unavailable_products": set(), + "warning_message": "", + } + + # Separate items into available and unavailable + available_items = [] + unavailable_items = [] + unavailable_product_ids = set() + + for item in items: + product_id = item.get("product_id") + if product_id in available_product_ids: + available_items.append(item) + else: + unavailable_items.append(item) + unavailable_product_ids.add(product_id) + + # Build warning message if there are unavailable items + warning_message = "" + if unavailable_items: + unavailable_names = [ + item.get("product_name", "Unknown") for item in unavailable_items + ] + warning_message = request.env._( + "%(count)d product(s) from your saved order are no longer available in this group order: %(names)s. " + "Only available products will be loaded.", + count=len(unavailable_items), + names=", ".join(unavailable_names), + ) + _logger.warning( + "load_order_from_history: %d unavailable items in group_order %d (products: %s)", + len(unavailable_items), + group_order.id, + unavailable_product_ids, + ) + + return { + "available_items": available_items, + "unavailable_items": unavailable_items, + "unavailable_products": unavailable_product_ids, + "warning_message": warning_message, + } + def _find_recent_draft_order(self, partner_id, group_order): """Find most recent draft sale.order for partner in the active order period. @@ -2218,12 +2299,31 @@ class AplicoopWebsiteSale(WebsiteSale): status=404, ) + # Determine if cutoff date for the current cycle has already passed + try: + today = fields.Date.today() + cutoff_passed = False + cutoff_date_str = None + if group_order.cutoff_date: + cutoff_passed = group_order.cutoff_date < today + # Convert to ISO-like string for frontend (YYYY-MM-DD) + cutoff_date_str = str(group_order.cutoff_date) + except Exception: + cutoff_passed = False + cutoff_date_str = None + response_data = { "success": True, "order_id": group_order.id, "state": group_order.state, "is_open": group_order.state == "open", - "action": "clear_cart" if group_order.state != "open" else "none", + "action": ( + "clear_cart" + if (group_order.state != "open" or cutoff_passed) + else "none" + ), + "cutoff_passed": cutoff_passed, + "cutoff_date": cutoff_date_str, } return request.make_response( json.dumps(response_data), @@ -2975,9 +3075,14 @@ class AplicoopWebsiteSale(WebsiteSale): if sale_order.group_order_id.id != group_order_id: return request.redirect("/eskaera/%d" % sale_order.group_order_id.id) + # Get the current group_order (the one being viewed, not necessarily the one from the history) + group_order = request.env["group.order"].sudo().browse(group_order_id) + if not group_order.exists(): + return request.redirect("/shop") + # Extract items from the order (skip delivery product) # Use the delivery_product_id from the group_order - delivery_product = sale_order.group_order_id.delivery_product_id + delivery_product = group_order.delivery_product_id delivery_product_id = delivery_product.id if delivery_product else None items = [] @@ -2995,6 +3100,21 @@ class AplicoopWebsiteSale(WebsiteSale): } ) + # Validate items against current group order availability + validation_result = self._validate_items_for_group_order(items, group_order) + available_items = validation_result["available_items"] + unavailable_items = validation_result["unavailable_items"] + warning_message = validation_result["warning_message"] + + _logger.info( + "load_order_from_history: Loaded %d items, %d available, %d unavailable from sale_order %d into group_order %d", + len(items), + len(available_items), + len(unavailable_items), + sale_order_id, + group_order_id, + ) + # Store items in localStorage by passing via URL parameter or session # We'll use sessionStorage in JavaScript to avoid URL length limits @@ -3019,13 +3139,19 @@ class AplicoopWebsiteSale(WebsiteSale): "website_sale_aplicoop.eskaera_load_from_history", { "group_order_id": group_order_id, - "items_json": json.dumps(items), # Pass serialized JSON + "items_json": json.dumps( + available_items + ), # Pass ONLY available items "sale_order": sale_order, "sale_order_name": sale_order.name, # Pass order reference "pickup_day": pickup_day_to_restore, # Pass pickup day (or None if different group) "pickup_date": pickup_date_to_restore, # Pass pickup date (or None if different group) "home_delivery": home_delivery_to_restore, # Pass home delivery flag (or None if different group) "same_group_order": same_group_order, # Indicate if from same group order + "unavailable_items": unavailable_items, # List of unavailable items + "warning_message": warning_message, # Warning about unavailable products + "has_unavailable_items": len(unavailable_items) + > 0, # Boolean flag for template }, ), ) @@ -3269,3 +3395,50 @@ class AplicoopWebsiteSale(WebsiteSale): "empty_cart": "Your cart is empty", "added_to_cart": "added to cart", } + + # ================================================================ + # CART REDIRECT METHODS - Redirect /shop/cart routes to /eskaera + # ================================================================ + + @http.route(["/shop/cart"], type="http", auth="public", website=True) + def cart_redirect(self, access_token=None, revive="", **post): + """Redirect /shop/cart to /eskaera (no standard cart).""" + _logger.info("🛒 Redirecting /shop/cart → /eskaera") + return http.redirect_with_hash("/eskaera") + + @http.route( + ["/shop/cart/update"], + type="http", + auth="public", + website=True, + methods=["POST"], + ) + def cart_update_redirect(self, **post): + """Redirect /shop/cart/update to /eskaera (no standard cart).""" + _logger.info("🛒 Redirecting /shop/cart/update → /eskaera") + return http.redirect_with_hash("/eskaera") + + @http.route( + ["/shop/cart/update_json"], + type="http", + auth="public", + website=True, + methods=["POST"], + csrf=False, + ) + def cart_update_json_redirect(self, **post): + """Redirect /shop/cart/update_json to /eskaera (no standard cart).""" + _logger.info("🛒 Redirecting /shop/cart/update_json → /eskaera") + return http.redirect_with_hash("/eskaera") + + @http.route( + ["/shop/cart_quantity"], + type="http", + auth="public", + website=True, + methods=["GET"], + ) + def cart_quantity_redirect(self): + """Redirect /shop/cart_quantity to /eskaera (no standard cart).""" + _logger.info("🛒 Redirecting /shop/cart_quantity → /eskaera") + return http.redirect_with_hash("/eskaera") diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 399e508..3d30765 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -882,7 +882,7 @@ class GroupOrder(models.Model): ) 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 picking type for this group order. Args: sale_orders: Recordset of confirmed sale.order @@ -902,9 +902,13 @@ class GroupOrder(models.Model): ) grouped_pickings[picking.picking_type_id.id] |= picking - scheduled_date = self.pickup_date - if not scheduled_date and self.delivery_date: - scheduled_date = self.delivery_date - timedelta(days=1) + scheduled_date = None + if self.pickup_date: + scheduled_date = fields.Datetime.to_datetime(self.pickup_date) + elif self.delivery_date: + scheduled_date = fields.Datetime.to_datetime( + self.delivery_date - timedelta(days=1) + ) for picking_type_id, pickings in grouped_pickings.items(): if not pickings: diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index 9e194a6..4c5bdb9 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -222,12 +222,14 @@ var pickupDayKey = "load_from_history_pickup_day_" + this.orderId; var pickupDateKey = "load_from_history_pickup_date_" + this.orderId; var homeDeliveryKey = "load_from_history_home_delivery_" + this.orderId; + var warningKey = "load_from_history_warning_" + this.orderId; var itemsJson = sessionStorage.getItem(storageKey); var orderName = sessionStorage.getItem(orderNameKey); var pickupDay = sessionStorage.getItem(pickupDayKey); var pickupDate = sessionStorage.getItem(pickupDateKey); var homeDelivery = sessionStorage.getItem(homeDeliveryKey) === "true"; + var warningMessage = sessionStorage.getItem(warningKey); console.log("DEBUG: _loadFromHistory called for orderId:", this.orderId); console.log("DEBUG: sessionStorageKey:", storageKey); @@ -240,6 +242,7 @@ homeDelivery, "(empty means different group order)" ); + console.log("DEBUG: warningMessage:", warningMessage); if (!itemsJson || itemsJson === "[object Object]") { console.log("No valid items from history found in sessionStorage"); @@ -248,6 +251,7 @@ sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); + sessionStorage.removeItem(warningKey); return; } @@ -269,6 +273,7 @@ sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); + sessionStorage.removeItem(warningKey); return; } @@ -349,12 +354,19 @@ } this._showNotification(message, "success", 3000); + // Show warning if some products were unavailable + if (warningMessage) { + console.log("Showing warning about unavailable products:", warningMessage); + this._showNotification(warningMessage, "warning", 5000); + } + // Clear sessionStorage sessionStorage.removeItem(storageKey); sessionStorage.removeItem(orderNameKey); sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); + sessionStorage.removeItem(warningKey); } catch (e) { console.error("Error loading from history:", e); console.error("itemsJson was:", itemsJson); @@ -435,8 +447,32 @@ } try { var data = JSON.parse(xhr.responseText || "{}"); - if (data && data.is_open === false) { + // Clear cart if order is closed, action requests clearance, or cutoff already passed + var shouldClear = false; + if (data) { + if (data.is_open === false) { + shouldClear = true; + } + if (data.action && data.action === "clear_cart") { + shouldClear = true; + } + if (data.cutoff_passed === true) { + shouldClear = true; + } + } + if (shouldClear) { + console.log( + "[groupOrderShop] check-status: clearing cart (reason:", + data, + ")" + ); self._clearCurrentOrderCartSilently(); + // Update on-screen cart if visible + try { + self._updateCartDisplay(); + } catch (err) { + console.warn("_updateCartDisplay failed after clearing cart:", err); + } } } catch (e) { console.warn("[groupOrderShop] check-status parse error", e); diff --git a/website_sale_aplicoop/tests/test_cron_picking_batch.py b/website_sale_aplicoop/tests/test_cron_picking_batch.py index f2cb3b5..152be12 100644 --- a/website_sale_aplicoop/tests/test_cron_picking_batch.py +++ b/website_sale_aplicoop/tests/test_cron_picking_batch.py @@ -361,6 +361,7 @@ class TestCronPickingBatch(TransactionCase): # Call second time group_order._confirm_linked_sale_orders() + so.invalidate_recordset() batch_second = so.picking_ids[0].batch_id batch_count_second = self.env["stock.picking.batch"].search_count( diff --git a/website_sale_aplicoop/views/load_from_history_templates.xml b/website_sale_aplicoop/views/load_from_history_templates.xml index 1f254df..5e3fb12 100644 --- a/website_sale_aplicoop/views/load_from_history_templates.xml +++ b/website_sale_aplicoop/views/load_from_history_templates.xml @@ -19,12 +19,17 @@ var homeDelivery = ; var sameGroupOrder = ; + // Product availability warning + var hasUnavailableItems = ; + var warningMessage = ''; + console.log('load_from_history template: groupOrderId=', groupOrderId); console.log('load_from_history template: saleOrderName=', saleOrderName); console.log('load_from_history template: pickupDay=', pickupDay); console.log('load_from_history template: pickupDate=', pickupDate); console.log('load_from_history template: homeDelivery=', homeDelivery); console.log('load_from_history template: sameGroupOrder=', sameGroupOrder); + console.log('load_from_history template: hasUnavailableItems=', hasUnavailableItems); console.log('load_from_history template: itemsJson type=', typeof itemsJson); console.log('load_from_history template: itemsJson value=', itemsJson); @@ -47,6 +52,12 @@ console.log('Skipped saving pickup fields (different group order - will use current group order days)'); } + // Store warning about unavailable products if they exist + if (hasUnavailableItems === 'true') { + sessionStorage['load_from_history_warning_' + groupOrderId] = warningMessage; + console.log('Unavailable products detected:', warningMessage); + } + console.log('Saved to sessionStorage[load_from_history_' + groupOrderId + ']:', itemsJsonString); console.log('Saved order name to sessionStorage[load_from_history_order_name_' + groupOrderId + ']:', saleOrderName); diff --git a/website_sale_aplicoop/views/website_sale_disable_cart.xml b/website_sale_aplicoop/views/website_sale_disable_cart.xml new file mode 100644 index 0000000..1733dd8 --- /dev/null +++ b/website_sale_aplicoop/views/website_sale_disable_cart.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + From 8f7eca45b8d2588627b23351d7a6141f4fdaa459 Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 19 May 2026 17:10:29 +0200 Subject: [PATCH 08/12] =?UTF-8?q?[IMP]=20website=5Fsale=5Faplicoop:=20disa?= =?UTF-8?q?ble=20standard=20website=5Fsale=20cart=20=E2=80=94=20hide=20hea?= =?UTF-8?q?der=20cart,=20remove=20add-to-cart,=20redirect=20cart=20routes?= =?UTF-8?q?=20to=20/eskaera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website_sale_aplicoop/README.rst | 46 +++++++++++++++++++ .../views/website_sale_disable_cart.xml | 33 +++++++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/website_sale_aplicoop/README.rst b/website_sale_aplicoop/README.rst index 40da165..bf621c1 100644 --- a/website_sale_aplicoop/README.rst +++ b/website_sale_aplicoop/README.rst @@ -141,3 +141,49 @@ This module was inspired by the original **Aplicoop** project: * Original creators: Ekaitz Mendiluze, Joseba Legarreta, and other contributors The original Aplicoop project served as a pioneering solution for collaborative consumption group orders, and this module brings its functionality to the modern Odoo platform. + +Notes - Shop behaves as a simple catalog +========================================= + +Starting with the recent update, this module converts the default Odoo +``/shop`` storefront into a simple product catalog (no standard ``website_sale`` +shopping cart). The change is intentional for sites that use the Aplicoop +"eskaera" flow as the single shopping experience. + +What the module does +--------------------- + +- Hides the standard header cart link and badge. +- Removes the "Add to cart" quick-add area from product listings. +- Redirects standard cart endpoints to the group-order flow (``/eskaera``): + ``/shop/cart``, ``/shop/cart/update``, ``/shop/cart/update_json``, ``/shop/cart_quantity``. + +Files involved +-------------- + +- ``views/website_sale_disable_cart.xml`` — templates that hide/remove cart UI +- ``controllers/website_sale.py`` — routes that redirect cart endpoints to ``/eskaera`` +- ``__manifest__.py`` — includes the new view file + +How to apply or revert +----------------------- + +To apply the change (already applied when the module is installed/updated): + +:: + + docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init + docker-compose up -d + +To revert back to the standard ``website_sale`` behaviour: + +1. Remove ``views/website_sale_disable_cart.xml`` from the ``data`` section in + ``__manifest__.py``. +2. Update the module: + +:: + + docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init + +Note: Reverting may expose standard cart UI and routes; ensure your site +content and workflows are adapted accordingly. diff --git a/website_sale_aplicoop/views/website_sale_disable_cart.xml b/website_sale_aplicoop/views/website_sale_disable_cart.xml index 1733dd8..842ec3f 100644 --- a/website_sale_aplicoop/views/website_sale_disable_cart.xml +++ b/website_sale_aplicoop/views/website_sale_disable_cart.xml @@ -6,11 +6,34 @@ Convert /shop to a simple product catalog ========================================== --> - -