[IMP] website_sale_aplicoop: filter and cap stock using forecasted net qty

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 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-06-12 18:18:40 +02:00
parent b484e3dc2e
commit 1d6747e703
7 changed files with 453 additions and 9 deletions

View file

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

View file

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