[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

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