[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

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