From 1d6747e703d5bad745d2446354d3a89ec0785880 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 12 Jun 2026 18:18:40 +0200 Subject: [PATCH] [IMP] website_sale_aplicoop: filter and cap stock using forecasted net qty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group orders are not confirmed until the cutoff date, so draft/sent sale.order lines never generate stock.moves and are invisible to virtual_available. This change makes the shop aware of that demand. - group.order._compute_draft_sale_demand: queries sale.order.line in draft/sent state (mirroring sale_stock forecasted report logic) and returns pending demand per product.id in the product's own UoM. - _get_products_for_group_order: delegates to new _apply_stock_filter_and_sort which excludes storable products whose forecasted net qty (virtual_available − draft demand) <= 0, unless allow_out_of_stock_order. - _compute_stock_ribbons: reads draft_demand_by_product from ORM context so is_out_of_stock / is_low_stock / dynamic_ribbon_id reflect net qty. - Controller: new _prepare_draft_stock_data helper calculates demand once per request, injects context, and builds product_max_qty dict. Applied in eskaera_shop, load_eskaera_page and load_products_ajax. - Template: qty input gets max and data-max-qty from product_max_qty. - JS: blocks add-to-cart if requested quantity exceeds data-max-qty. - Fixes type check: type=='consu' → is_storable=True (Odoo 18 semantics). - 21 new tests in test_forecasted_stock.py covering demand calculation, ribbon logic with context, and group order filtering. Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/website_sale.py | 31 ++ website_sale_aplicoop/models/group_order.py | 54 ++- .../models/product_extension.py | 11 +- .../static/src/js/website_sale.js | 15 + website_sale_aplicoop/tests/__init__.py | 1 + .../tests/test_forecasted_stock.py | 348 ++++++++++++++++++ .../views/website_templates.xml | 2 +- 7 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 website_sale_aplicoop/tests/test_forecasted_stock.py diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 315143a..fad2dbf 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -572,6 +572,25 @@ class AplicoopWebsiteSale(WebsiteSale): """Delegate preparation of product maps to products helper.""" return _products._prepare_products_maps(self, products, pricelist) + def _prepare_draft_stock_data(self, products): + """Return (products_with_context, product_max_qty) for Eskaera stock rendering. + + Computes draft/sent sale.order demand and injects it as context so that + is_out_of_stock / is_low_stock / dynamic_ribbon_id reflect forecasted net qty. + product_max_qty maps product.id → net qty for storable products where + allow_out_of_stock_order is False and net qty > 0 (used as input[max]). + """ + draft_demand = request.env["group.order"]._compute_draft_sale_demand(products) + products_ctx = products.with_context(draft_demand_by_product=draft_demand) + product_max_qty = {} + for p in products_ctx: + allow_oos = getattr(p, "allow_out_of_stock_order", True) + if not allow_oos and getattr(p.product_tmpl_id, "is_storable", False): + net = p.virtual_available - draft_demand.get(p.id, 0.0) + if net > 0: + product_max_qty[p.id] = net + return products_ctx, product_max_qty + def _merge_or_replace_draft( self, group_order, @@ -790,6 +809,9 @@ class AplicoopWebsiteSale(WebsiteSale): filtered_products_dict, ) = self._prepare_products_maps(products, pricelist) + # Inject draft sale demand context so ribbons/is_out_of_stock use forecasted net qty + products, product_max_qty = self._prepare_draft_stock_data(products) + # Manage session for separate cart per order session_key = f"eskaera_{order_id}" cart = request.session.get(session_key, {}) @@ -838,6 +860,7 @@ class AplicoopWebsiteSale(WebsiteSale): "has_next": has_next, "total_products": total_products, "module_version": self._get_installed_module_version(), + "product_max_qty": product_max_qty, }, ) @@ -931,6 +954,9 @@ class AplicoopWebsiteSale(WebsiteSale): product, product_price_info ) + # Inject draft demand context for ribbons/stock flags + products_page, product_max_qty = self._prepare_draft_stock_data(products_page) + labels = self.get_checkout_labels() return request.render( @@ -945,6 +971,7 @@ class AplicoopWebsiteSale(WebsiteSale): "labels": labels, "has_next": has_next, "next_page": page + 1, + "product_max_qty": product_max_qty, }, ) @@ -1047,6 +1074,9 @@ class AplicoopWebsiteSale(WebsiteSale): for product in products_page } + # Inject draft demand context for ribbons/stock flags + products_page, product_max_qty = self._prepare_draft_stock_data(products_page) + # Render HTML html = ( request.env["ir.ui.view"] @@ -1063,6 +1093,7 @@ class AplicoopWebsiteSale(WebsiteSale): "labels": self.get_checkout_labels(), "has_next": has_next, "next_page": page + 1, + "product_max_qty": product_max_qty, }, ) ) diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 7b3ed6a..97dad3c 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -483,16 +483,64 @@ class GroupOrder(models.Model): len(products), ) - # Sort products: in-stock first, then out-of-stock, maintaining sequence+name within each group - # is_out_of_stock is Boolean: False (in stock) comes first, True (out of stock) comes last + return self._apply_stock_filter_and_sort(products, order) + + def _apply_stock_filter_and_sort(self, products, order): + """Filter out storable products with no forecasted net stock, then sort. + + Forecasted net qty = virtual_available − draft/sent sale.order demand. + Products with allow_out_of_stock_order=True or non-storable are never excluded. + """ + draft_demand = self._compute_draft_sale_demand(products) + before = len(products) + products = products.filtered( + lambda p: getattr(p, "allow_out_of_stock_order", True) + or not getattr(p.product_tmpl_id, "is_storable", False) + or (p.virtual_available - draft_demand.get(p.id, 0.0)) > 0 + ) + if len(products) < before: + _logger.info( + "Group order %d: Excluded %d products with no forecasted net stock (total: %d)", + order.id, + before - len(products), + len(products), + ) return products.sorted( lambda p: ( - p.is_out_of_stock, # Boolean: False < True, so in-stock products first + p.is_out_of_stock, p.product_tmpl_id.website_sequence, (p.name or "").lower(), ) ) + @api.model + def _compute_draft_sale_demand(self, products): + """Return pending draft/sent sale.order demand per product.id (qty in product UoM). + + Mirrors the logic in sale_stock/report/stock_forecasted.py used by the + "Informe pronosticado" report, which includes quotations not yet confirmed. + """ + if not products: + return {} + sol = ( + self.env["sale.order.line"] + .sudo() + .search( + [ + ("state", "in", ["draft", "sent"]), + ("product_id", "in", products.ids), + ] + ) + ) + demand = {p.id: 0.0 for p in products} + for line in sol: + pid = line.product_id.id + if pid in demand: + demand[pid] += line.product_uom._compute_quantity( + line.product_uom_qty, line.product_id.uom_id + ) + return demand + def _get_products_paginated(self, order_id, page=1, per_page=20): """Get paginated products for a group order. diff --git a/website_sale_aplicoop/models/product_extension.py b/website_sale_aplicoop/models/product_extension.py index 9ed389e..eb945a1 100644 --- a/website_sale_aplicoop/models/product_extension.py +++ b/website_sale_aplicoop/models/product_extension.py @@ -22,13 +22,13 @@ class ProductProduct(models.Model): is_out_of_stock = fields.Boolean( compute="_compute_stock_ribbons", store=False, - help="True if virtual_available <= 0", + help="True if forecasted net qty <= 0 (virtual_available minus draft sale demand)", ) is_low_stock = fields.Boolean( compute="_compute_stock_ribbons", store=False, - help="True if 0 < virtual_available <= threshold", + help="True if 0 < forecasted net qty <= threshold", ) dynamic_ribbon_id = fields.Many2one( @@ -67,14 +67,15 @@ class ProductProduct(models.Model): ) for product in self: - # Solo para productos almacenables (type='consu' en Odoo 18) - if product.type != "consu": + # Solo para productos almacenables (is_storable=True en Odoo 18) + if not getattr(product.product_tmpl_id, "is_storable", False): product.is_out_of_stock = False product.is_low_stock = False product.dynamic_ribbon_id = False continue - qty = product.virtual_available + draft_demand = self.env.context.get("draft_demand_by_product", {}) + qty = product.virtual_available - draft_demand.get(product.id, 0.0) # Check if product allows selling when out of stock # If True, never block add-to-cart based on stock diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index 1ed420b..1777c57 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -1086,6 +1086,21 @@ return; } + // Block if requested qty exceeds forecasted net stock + var maxQtyAttr = quantityInput && quantityInput.getAttribute("data-max-qty"); + var maxQty = maxQtyAttr ? parseFloat(maxQtyAttr) : NaN; + if (!isNaN(maxQty) && quantity > maxQty) { + var labelsStock = self._getLabels(); + self._showNotification( + (labelsStock.exceeds_stock || "Cannot add more than available stock") + + " (" + + maxQty + + ")", + "warning" + ); + return; + } + console.log("Adding:", { productId: productId, productName: productName, diff --git a/website_sale_aplicoop/tests/__init__.py b/website_sale_aplicoop/tests/__init__.py index a8db682..0ca620d 100644 --- a/website_sale_aplicoop/tests/__init__.py +++ b/website_sale_aplicoop/tests/__init__.py @@ -16,3 +16,4 @@ from . import test_portal_sale_order_creation # noqa: F401 from . import test_cron_picking_batch # noqa: F401 from . import test_group_order_status_endpoint # noqa: F401 from . import test_home_delivery # noqa: F401 +from . import test_forecasted_stock # noqa: F401 diff --git a/website_sale_aplicoop/tests/test_forecasted_stock.py b/website_sale_aplicoop/tests/test_forecasted_stock.py new file mode 100644 index 0000000..024b86f --- /dev/null +++ b/website_sale_aplicoop/tests/test_forecasted_stock.py @@ -0,0 +1,348 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +""" +Tests para el filtrado por stock pronosticado neto en Eskaera. + +Cubre: +- _compute_draft_sale_demand: cálculo de demanda de presupuestos draft/sent +- _compute_stock_ribbons: ribbons usando qty neta con contexto draft_demand_by_product +- _get_products_for_group_order: exclusión de productos sin stock neto +""" + +from datetime import datetime +from datetime import timedelta + +from odoo.tests.common import TransactionCase + + +class TestDraftSaleDemand(TransactionCase): + """Tests para group.order._compute_draft_sale_demand.""" + + def setUp(self): + super().setUp() + self.stock_location = self.env.ref("stock.stock_location_stock") + self.uom_unit = self.env.ref("uom.product_uom_unit") + + self.product_a = self.env["product.product"].create( + { + "name": "Product A", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "is_published": True, + "sale_ok": True, + } + ) + self.product_b = self.env["product.product"].create( + { + "name": "Product B", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "is_published": True, + "sale_ok": True, + } + ) + self.partner = self.env["res.partner"].create({"name": "Test Customer"}) + + def _make_sale_order(self, product, qty, state="draft"): + """Helper: create a sale.order with one line.""" + so = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "price_unit": 10.0, + "name": product.name, + }, + ) + ], + } + ) + if state == "sent": + so.action_quotation_sent() + elif state == "sale": + so.action_confirm() + return so + + def test_no_draft_orders_returns_zeros(self): + """Sin presupuestos, la demanda es cero.""" + products = self.product_a | self.product_b + demand = self.env["group.order"]._compute_draft_sale_demand(products) + self.assertEqual(demand[self.product_a.id], 0.0) + self.assertEqual(demand[self.product_b.id], 0.0) + + def test_draft_order_counted(self): + """Un presupuesto en draft se cuenta.""" + self._make_sale_order(self.product_a, 5.0, state="draft") + demand = self.env["group.order"]._compute_draft_sale_demand(self.product_a) + self.assertEqual(demand[self.product_a.id], 5.0) + + def test_sent_order_counted(self): + """Un presupuesto en estado 'sent' también se cuenta.""" + self._make_sale_order(self.product_a, 3.0, state="sent") + demand = self.env["group.order"]._compute_draft_sale_demand(self.product_a) + self.assertEqual(demand[self.product_a.id], 3.0) + + def test_confirmed_order_not_counted(self): + """Un pedido confirmado (state='sale') no impacta la demanda draft.""" + self._make_sale_order(self.product_a, 7.0, state="sale") + demand = self.env["group.order"]._compute_draft_sale_demand(self.product_a) + self.assertEqual(demand[self.product_a.id], 0.0) + + def test_multiple_draft_orders_summed(self): + """Varios presupuestos draft se suman.""" + self._make_sale_order(self.product_a, 4.0, state="draft") + self._make_sale_order(self.product_a, 6.0, state="draft") + demand = self.env["group.order"]._compute_draft_sale_demand(self.product_a) + self.assertEqual(demand[self.product_a.id], 10.0) + + def test_multiple_products_isolated(self): + """La demanda de cada producto es independiente.""" + self._make_sale_order(self.product_a, 3.0, state="draft") + self._make_sale_order(self.product_b, 8.0, state="draft") + products = self.product_a | self.product_b + demand = self.env["group.order"]._compute_draft_sale_demand(products) + self.assertEqual(demand[self.product_a.id], 3.0) + self.assertEqual(demand[self.product_b.id], 8.0) + + def test_empty_recordset_returns_empty_dict(self): + """Con recordset vacío, devuelve dict vacío.""" + products = self.env["product.product"].browse() + demand = self.env["group.order"]._compute_draft_sale_demand(products) + self.assertEqual(demand, {}) + + +class TestStockRibbonsWithDraftDemand(TransactionCase): + """Tests para _compute_stock_ribbons con contexto draft_demand_by_product.""" + + def setUp(self): + super().setUp() + self.stock_location = self.env.ref("stock.stock_location_stock") + self.uom_unit = self.env.ref("uom.product_uom_unit") + self.partner = self.env["res.partner"].create({"name": "Ribbon Test Customer"}) + + self.product = self.env["product.product"].create( + { + "name": "Storable Ribbon Product", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "allow_out_of_stock_order": False, + } + ) + # Configurar umbral bajo para facilitar tests de pocas existencias + self.env["ir.config_parameter"].sudo().set_param( + "website_sale_aplicoop.low_stock_threshold", "5.0" + ) + + def _set_stock(self, qty): + if qty: + self.env["stock.quant"].sudo()._update_available_quantity( + self.product, self.stock_location, qty + ) + + def _make_draft_demand(self, qty): + return {self.product.id: qty} + + def test_in_stock_no_demand_no_ribbon(self): + """Stock suficiente, sin demanda draft → sin ribbon.""" + self._set_stock(10.0) + p = self.product.with_context(draft_demand_by_product={self.product.id: 0.0}) + self.assertFalse(p.is_out_of_stock) + self.assertFalse(p.is_low_stock) + + def test_draft_demand_reduces_to_low_stock(self): + """Stock 10, demanda draft 8 → qty neta 2 → pocas existencias.""" + self._set_stock(10.0) + p = self.product.with_context( + draft_demand_by_product=self._make_draft_demand(8.0) + ) + self.assertFalse(p.is_out_of_stock) + self.assertTrue(p.is_low_stock) + + def test_draft_demand_reduces_to_out_of_stock(self): + """Stock 5, demanda draft 6 → qty neta -1 → fuera de stock.""" + self._set_stock(5.0) + p = self.product.with_context( + draft_demand_by_product=self._make_draft_demand(6.0) + ) + self.assertTrue(p.is_out_of_stock) + self.assertFalse(p.is_low_stock) + + def test_without_context_uses_virtual_available(self): + """Sin contexto, el ribbon usa virtual_available (comportamiento anterior).""" + # Sin stock añadido → virtual_available = 0 → is_out_of_stock = True + self.assertTrue(self.product.is_out_of_stock) + + def test_allow_out_of_stock_order_bypasses_ribbon(self): + """Con allow_out_of_stock_order=True nunca hay ribbon, aunque qty neta sea negativa.""" + self._set_stock(1.0) + self.product.write({"allow_out_of_stock_order": True}) + p = self.product.with_context( + draft_demand_by_product=self._make_draft_demand(10.0) + ) + self.assertFalse(p.is_out_of_stock) + self.assertFalse(p.is_low_stock) + + def test_service_product_never_has_ribbon(self): + """Producto de tipo servicio nunca tiene ribbon de stock.""" + service = self.env["product.product"].create( + {"name": "Service", "type": "service"} + ) + p = service.with_context(draft_demand_by_product={service.id: 999.0}) + self.assertFalse(p.is_out_of_stock) + self.assertFalse(p.is_low_stock) + + +class TestGroupOrderForecastedFilter(TransactionCase): + """Tests para el filtro de stock pronosticado neto en _get_products_for_group_order.""" + + def setUp(self): + super().setUp() + self.stock_location = self.env.ref("stock.stock_location_stock") + self.uom_unit = self.env.ref("uom.product_uom_unit") + self.partner = self.env["res.partner"].create({"name": "Filter Test Customer"}) + + start_date = datetime.now().date() + self.group_order = self.env["group.order"].create( + { + "name": "Filter Test Order", + "type": "regular", + "start_date": start_date, + "end_date": start_date + timedelta(days=7), + "period": "weekly", + "pickup_day": "3", + "cutoff_day": "0", + } + ) + + def _storable(self, name, stock=0.0, allow_oos=False): + """Helper: crear producto almacenable con stock y configuración dada.""" + p = self.env["product.product"].create( + { + "name": name, + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "is_published": True, + "sale_ok": True, + "allow_out_of_stock_order": allow_oos, + } + ) + if stock: + self.env["stock.quant"].sudo()._update_available_quantity( + p, self.stock_location, stock + ) + return p + + def _draft_so(self, product, qty): + """Helper: crear sale.order draft con una línea.""" + return self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "price_unit": 10.0, + "name": product.name, + }, + ) + ], + } + ) + + def test_product_with_sufficient_net_stock_included(self): + """Producto con stock neto positivo aparece en el pedido de grupo.""" + p = self._storable("Enough Stock", stock=10.0) + self._draft_so(p, 3.0) # neto = 7 + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertIn(p, products) + + def test_product_with_zero_net_stock_excluded(self): + """Producto con stock neto = 0 es excluido.""" + p = self._storable("Exact Zero", stock=5.0) + self._draft_so(p, 5.0) # neto = 0 + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertNotIn(p, products) + + def test_product_with_negative_net_stock_excluded(self): + """Producto con stock neto negativo (más demanda que stock) es excluido.""" + p = self._storable("Negative Net", stock=3.0) + self._draft_so(p, 7.0) # neto = -4 + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertNotIn(p, products) + + def test_product_with_allow_oos_always_included(self): + """Producto con allow_out_of_stock_order=True aparece aunque no haya stock neto.""" + p = self._storable("Allow OOS", stock=2.0, allow_oos=True) + self._draft_so(p, 10.0) # neto = -8, pero allow_oos=True + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertIn(p, products) + + def test_service_product_always_included(self): + """Productos de servicio (type!='consu') no se filtran por stock.""" + service = self.env["product.product"].create( + { + "name": "Service Product", + "type": "service", + "is_published": True, + "sale_ok": True, + } + ) + self.group_order.product_ids = [(4, service.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertIn(service, products) + + def test_confirmed_so_does_not_trigger_exclusion(self): + """Un pedido confirmado (no draft) no reduce el stock neto para el filtro.""" + p = self._storable("Confirmed SO", stock=5.0) + so = self._draft_so(p, 5.0) + so.action_confirm() # confirmado → no cuenta en draft_demand + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + # virtual_available ya incluye el movimiento de salida confirmado, + # pero draft_demand es 0 → el filtro usa virtual_available sin descuento + # El producto puede o no estar según el stock real, pero no se excluye por draft + # En este test simplemente verificamos que no lanza error + self.assertIsNotNone(products) + + def test_no_draft_demand_product_with_stock_included(self): + """Producto sin ningún presupuesto draft y con stock positivo siempre incluido.""" + p = self._storable("No Demand", stock=8.0) + self.group_order.product_ids = [(4, p.id)] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertIn(p, products) + + def test_mixed_products_only_net_positive_included(self): + """De varios productos, solo los con stock neto > 0 aparecen.""" + p_ok = self._storable("Has Stock", stock=10.0) + p_ko = self._storable("No Stock", stock=2.0) + self._draft_so(p_ko, 3.0) # neto = -1 + self.group_order.product_ids = [ + (4, p_ok.id), + (4, p_ko.id), + ] + products = self.group_order._get_products_for_group_order(self.group_order.id) + self.assertIn(p_ok, products) + self.assertNotIn(p_ko, products) diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index 3d4d6ac..dce168e 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -720,7 +720,7 @@ - +