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>
132 lines
4.7 KiB
Python
132 lines
4.7 KiB
Python
# Copyright 2025 - Today Criptomart
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
from odoo import api
|
|
from odoo import fields
|
|
from odoo import models
|
|
|
|
|
|
class ProductProduct(models.Model):
|
|
_inherit = "product.product"
|
|
|
|
group_order_ids = fields.Many2many(
|
|
"group.order",
|
|
"group_order_product_rel",
|
|
"product_id",
|
|
"order_id",
|
|
string="Group Orders",
|
|
readonly=True,
|
|
help="Group orders where this product is available",
|
|
)
|
|
|
|
is_out_of_stock = fields.Boolean(
|
|
compute="_compute_stock_ribbons",
|
|
store=False,
|
|
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 < forecasted net qty <= threshold",
|
|
)
|
|
|
|
dynamic_ribbon_id = fields.Many2one(
|
|
"product.ribbon",
|
|
string="Dynamic Stock Ribbon",
|
|
compute="_compute_stock_ribbons",
|
|
store=False,
|
|
help="Auto-assigned ribbon based on stock levels",
|
|
)
|
|
|
|
@api.depends("virtual_available", "type", "allow_out_of_stock_order")
|
|
def _compute_stock_ribbons(self):
|
|
"""Compute stock-based ribbons dynamically.
|
|
|
|
A product is considered out of stock only when:
|
|
- It is a storable product (type='consu')
|
|
- virtual_available <= 0
|
|
- allow_out_of_stock_order is False
|
|
|
|
If allow_out_of_stock_order is True, the product can always be sold
|
|
regardless of stock level.
|
|
"""
|
|
# Obtener ribbons (usar sudo para evitar permisos)
|
|
out_of_stock_ribbon = self.env.ref(
|
|
"website_sale_aplicoop.out_of_stock_ribbon", raise_if_not_found=False
|
|
)
|
|
low_stock_ribbon = self.env.ref(
|
|
"website_sale_aplicoop.low_stock_ribbon", raise_if_not_found=False
|
|
)
|
|
|
|
# Obtener threshold de configuración
|
|
threshold = float(
|
|
self.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.low_stock_threshold", default="5.0")
|
|
)
|
|
|
|
for product in self:
|
|
# 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
|
|
|
|
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
|
|
allow_oos = getattr(product, "allow_out_of_stock_order", True)
|
|
|
|
if qty <= 0 and not allow_oos:
|
|
# Out of stock and NOT allowed to sell without stock
|
|
product.is_out_of_stock = True
|
|
product.is_low_stock = False
|
|
product.dynamic_ribbon_id = out_of_stock_ribbon
|
|
elif qty <= 0 and allow_oos:
|
|
# Out of stock but allowed to sell anyway
|
|
product.is_out_of_stock = False
|
|
product.is_low_stock = False
|
|
product.dynamic_ribbon_id = False
|
|
elif qty <= threshold:
|
|
product.is_out_of_stock = False
|
|
product.is_low_stock = True
|
|
product.dynamic_ribbon_id = low_stock_ribbon
|
|
else:
|
|
product.is_out_of_stock = False
|
|
product.is_low_stock = False
|
|
product.dynamic_ribbon_id = False
|
|
|
|
@api.model
|
|
def _get_products_for_group_order(self, order_id):
|
|
"""Backward-compatible delegation to `group.order` discovery.
|
|
|
|
The canonical discovery logic lives on `group.order` to keep
|
|
responsibilities together. Keep this wrapper so existing callers
|
|
on `product.product` keep working.
|
|
"""
|
|
order = self.env["group.order"].browse(order_id)
|
|
if not order.exists():
|
|
return self.browse()
|
|
return order._get_products_for_group_order(order.id)
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_inherit = "product.template"
|
|
|
|
group_order_ids = fields.Many2many(
|
|
"group.order",
|
|
compute="_compute_group_order_ids",
|
|
string="Consumer Group Orders",
|
|
readonly=True,
|
|
help="Consumer group orders where variants of this product are available",
|
|
)
|
|
|
|
@api.depends("product_variant_ids.group_order_ids")
|
|
def _compute_group_order_ids(self):
|
|
for template in self:
|
|
variants = template.product_variant_ids
|
|
template.group_order_ids = variants.mapped("group_order_ids")
|