[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:
parent
b484e3dc2e
commit
1d6747e703
7 changed files with 453 additions and 9 deletions
|
|
@ -572,6 +572,25 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"""Delegate preparation of product maps to products helper."""
|
"""Delegate preparation of product maps to products helper."""
|
||||||
return _products._prepare_products_maps(self, products, pricelist)
|
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(
|
def _merge_or_replace_draft(
|
||||||
self,
|
self,
|
||||||
group_order,
|
group_order,
|
||||||
|
|
@ -790,6 +809,9 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
filtered_products_dict,
|
filtered_products_dict,
|
||||||
) = self._prepare_products_maps(products, pricelist)
|
) = 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
|
# Manage session for separate cart per order
|
||||||
session_key = f"eskaera_{order_id}"
|
session_key = f"eskaera_{order_id}"
|
||||||
cart = request.session.get(session_key, {})
|
cart = request.session.get(session_key, {})
|
||||||
|
|
@ -838,6 +860,7 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"has_next": has_next,
|
"has_next": has_next,
|
||||||
"total_products": total_products,
|
"total_products": total_products,
|
||||||
"module_version": self._get_installed_module_version(),
|
"module_version": self._get_installed_module_version(),
|
||||||
|
"product_max_qty": product_max_qty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -931,6 +954,9 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
product, product_price_info
|
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()
|
labels = self.get_checkout_labels()
|
||||||
|
|
||||||
return request.render(
|
return request.render(
|
||||||
|
|
@ -945,6 +971,7 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"has_next": has_next,
|
"has_next": has_next,
|
||||||
"next_page": page + 1,
|
"next_page": page + 1,
|
||||||
|
"product_max_qty": product_max_qty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1047,6 +1074,9 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
for product in products_page
|
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
|
# Render HTML
|
||||||
html = (
|
html = (
|
||||||
request.env["ir.ui.view"]
|
request.env["ir.ui.view"]
|
||||||
|
|
@ -1063,6 +1093,7 @@ class AplicoopWebsiteSale(WebsiteSale):
|
||||||
"labels": self.get_checkout_labels(),
|
"labels": self.get_checkout_labels(),
|
||||||
"has_next": has_next,
|
"has_next": has_next,
|
||||||
"next_page": page + 1,
|
"next_page": page + 1,
|
||||||
|
"product_max_qty": product_max_qty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -483,16 +483,64 @@ class GroupOrder(models.Model):
|
||||||
len(products),
|
len(products),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort products: in-stock first, then out-of-stock, maintaining sequence+name within each group
|
return self._apply_stock_filter_and_sort(products, order)
|
||||||
# is_out_of_stock is Boolean: False (in stock) comes first, True (out of stock) comes last
|
|
||||||
|
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(
|
return products.sorted(
|
||||||
lambda p: (
|
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.product_tmpl_id.website_sequence,
|
||||||
(p.name or "").lower(),
|
(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):
|
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
||||||
"""Get paginated products for a group order.
|
"""Get paginated products for a group order.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ class ProductProduct(models.Model):
|
||||||
is_out_of_stock = fields.Boolean(
|
is_out_of_stock = fields.Boolean(
|
||||||
compute="_compute_stock_ribbons",
|
compute="_compute_stock_ribbons",
|
||||||
store=False,
|
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(
|
is_low_stock = fields.Boolean(
|
||||||
compute="_compute_stock_ribbons",
|
compute="_compute_stock_ribbons",
|
||||||
store=False,
|
store=False,
|
||||||
help="True if 0 < virtual_available <= threshold",
|
help="True if 0 < forecasted net qty <= threshold",
|
||||||
)
|
)
|
||||||
|
|
||||||
dynamic_ribbon_id = fields.Many2one(
|
dynamic_ribbon_id = fields.Many2one(
|
||||||
|
|
@ -67,14 +67,15 @@ class ProductProduct(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
for product in self:
|
for product in self:
|
||||||
# Solo para productos almacenables (type='consu' en Odoo 18)
|
# Solo para productos almacenables (is_storable=True en Odoo 18)
|
||||||
if product.type != "consu":
|
if not getattr(product.product_tmpl_id, "is_storable", False):
|
||||||
product.is_out_of_stock = False
|
product.is_out_of_stock = False
|
||||||
product.is_low_stock = False
|
product.is_low_stock = False
|
||||||
product.dynamic_ribbon_id = False
|
product.dynamic_ribbon_id = False
|
||||||
continue
|
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
|
# Check if product allows selling when out of stock
|
||||||
# If True, never block add-to-cart based on stock
|
# If True, never block add-to-cart based on stock
|
||||||
|
|
|
||||||
|
|
@ -1086,6 +1086,21 @@
|
||||||
return;
|
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:", {
|
console.log("Adding:", {
|
||||||
productId: productId,
|
productId: productId,
|
||||||
productName: productName,
|
productName: productName,
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,4 @@ from . import test_portal_sale_order_creation # noqa: F401
|
||||||
from . import test_cron_picking_batch # noqa: F401
|
from . import test_cron_picking_batch # noqa: F401
|
||||||
from . import test_group_order_status_endpoint # noqa: F401
|
from . import test_group_order_status_endpoint # noqa: F401
|
||||||
from . import test_home_delivery # noqa: F401
|
from . import test_home_delivery # noqa: F401
|
||||||
|
from . import test_forecasted_stock # noqa: F401
|
||||||
|
|
|
||||||
348
website_sale_aplicoop/tests/test_forecasted_stock.py
Normal file
348
website_sale_aplicoop/tests/test_forecasted_stock.py
Normal 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)
|
||||||
|
|
@ -720,7 +720,7 @@
|
||||||
<button class="qty-decrease" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Decrease quantity">
|
<button class="qty-decrease" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Decrease quantity">
|
||||||
<i class="fa fa-minus" />
|
<i class="fa fa-minus" />
|
||||||
</button>
|
</button>
|
||||||
<input type="number" t-attf-id="qty_{{ product.id }}" class="product-qty" name="quantity" t-attf-value="1" t-attf-min="{{ quantity_step }}" t-attf-step="{{ quantity_step }}" />
|
<input type="number" t-attf-id="qty_{{ product.id }}" class="product-qty" name="quantity" t-attf-value="1" t-attf-min="{{ quantity_step }}" t-attf-step="{{ quantity_step }}" t-att-max="product_max_qty.get(product.id) if product_max_qty else None" t-att-data-max-qty="product_max_qty.get(product.id) if product_max_qty else None" />
|
||||||
<button class="qty-increase" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Increase quantity">
|
<button class="qty-increase" type="button" t-attf-data-product-id="{{ product.id }}" aria-label="Increase quantity">
|
||||||
<i class="fa fa-plus" />
|
<i class="fa fa-plus" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue