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