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>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
# 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)
|