[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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue