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