feat(website_sale_aplicoop): sistema de ribbons basado en stock
- Añadidos ribbons 'Sin Stock' (rojo) y 'Pocas Existencias' (amarillo) - Nuevo campo configurable: umbral de stock bajo (default: 5.0) - Campos computed en product.product: * is_out_of_stock: True cuando qty_available <= 0 * is_low_stock: True cuando 0 < qty_available <= threshold * dynamic_ribbon_id: ribbon automático según nivel de stock - Ordenamiento mejorado: productos con stock primero, sin stock al final - Template actualizado: * Muestra ribbon de stock en tarjeta de producto * Deshabilita botón add-to-cart cuando producto sin stock * Cambia icono a 'fa-ban' en productos sin stock - Vista de configuración: campo 'Low Stock Threshold' en Settings > Shop Performance - Solo aplica a productos type='consu' (almacenables) - Tests: 112 pasados, 0 fallos
This commit is contained in:
parent
539cd5cccd
commit
2a005a9d5a
7 changed files with 132 additions and 6 deletions
|
|
@ -26,6 +26,8 @@
|
||||||
"data/website_menus.xml",
|
"data/website_menus.xml",
|
||||||
# Datos: Cron jobs
|
# Datos: Cron jobs
|
||||||
"data/cron.xml",
|
"data/cron.xml",
|
||||||
|
# Datos: Product ribbons for stock levels
|
||||||
|
"data/product_ribbon_data.xml",
|
||||||
# Vistas de seguridad
|
# Vistas de seguridad
|
||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
"security/record_rules.xml",
|
"security/record_rules.xml",
|
||||||
|
|
|
||||||
22
website_sale_aplicoop/data/product_ribbon_data.xml
Normal file
22
website_sale_aplicoop/data/product_ribbon_data.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright 2026 Criptomart -->
|
||||||
|
<!-- License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="0">
|
||||||
|
<!-- Ribbon: Out of Stock (Sin Stock) -->
|
||||||
|
<record id="out_of_stock_ribbon" model="product.ribbon">
|
||||||
|
<field name="name">Sin Stock</field>
|
||||||
|
<field name="position">left</field>
|
||||||
|
<field name="text_color">#FFFFFF</field>
|
||||||
|
<field name="bg_color">#d9534f</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Ribbon: Low Stock (Pocas Existencias) -->
|
||||||
|
<record id="low_stock_ribbon" model="product.ribbon">
|
||||||
|
<field name="name">Pocas Existencias</field>
|
||||||
|
<field name="position">left</field>
|
||||||
|
<field name="text_color">#FFFFFF</field>
|
||||||
|
<field name="bg_color">#ffc107</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
|
|
@ -477,9 +477,14 @@ class GroupOrder(models.Model):
|
||||||
len(products),
|
len(products),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort products by website_sequence (primary) and name (secondary, case-insensitive)
|
# 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 products.sorted(
|
return products.sorted(
|
||||||
lambda p: (p.product_tmpl_id.website_sequence, (p.name or "").lower())
|
lambda p: (
|
||||||
|
p.is_out_of_stock, # Boolean: False < True, so in-stock products first
|
||||||
|
p.product_tmpl_id.website_sequence,
|
||||||
|
(p.name or "").lower(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,71 @@ class ProductProduct(models.Model):
|
||||||
help="Group orders where this product is available",
|
help="Group orders where this product is available",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_out_of_stock = fields.Boolean(
|
||||||
|
string="Is Out of Stock",
|
||||||
|
compute="_compute_stock_ribbons",
|
||||||
|
store=False,
|
||||||
|
help="True if qty_available <= 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_low_stock = fields.Boolean(
|
||||||
|
string="Is Low Stock",
|
||||||
|
compute="_compute_stock_ribbons",
|
||||||
|
store=False,
|
||||||
|
help="True if 0 < qty_available <= 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("qty_available", "type")
|
||||||
|
def _compute_stock_ribbons(self):
|
||||||
|
"""Compute stock-based ribbons dynamically."""
|
||||||
|
# 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 (type='consu' o 'product' en algunos casos)
|
||||||
|
# En Odoo 18: 'consu' = Goods (almacenable), 'service' = Service, 'combo' = Combo
|
||||||
|
if product.type != "consu":
|
||||||
|
product.is_out_of_stock = False
|
||||||
|
product.is_low_stock = False
|
||||||
|
product.dynamic_ribbon_id = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lógica de stock
|
||||||
|
qty = product.qty_available
|
||||||
|
|
||||||
|
if qty <= 0:
|
||||||
|
product.is_out_of_stock = True
|
||||||
|
product.is_low_stock = False
|
||||||
|
product.dynamic_ribbon_id = out_of_stock_ribbon
|
||||||
|
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 # No ribbon
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _get_products_for_group_order(self, order_id):
|
def _get_products_for_group_order(self, order_id):
|
||||||
"""Backward-compatible delegation to `group.order` discovery.
|
"""Backward-compatible delegation to `group.order` discovery.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ class ResConfigSettings(models.TransientModel):
|
||||||
help="Number of products to load per page in group order shop. Minimum 5, Maximum 100.",
|
help="Number of products to load per page in group order shop. Minimum 5, Maximum 100.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
low_stock_threshold = fields.Float(
|
||||||
|
string="Low Stock Threshold",
|
||||||
|
config_parameter="website_sale_aplicoop.low_stock_threshold",
|
||||||
|
default=5.0,
|
||||||
|
help="Products with stock below or equal to this value will show 'Low Stock' ribbon. "
|
||||||
|
"Products with stock = 0 will show 'Out of Stock' ribbon and cannot be added to cart.",
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_products_per_page_selection(records):
|
def _get_products_per_page_selection(records):
|
||||||
"""Return default page sizes."""
|
"""Return default page sizes."""
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
|
<div class="o_setting_left_pane"/>
|
||||||
|
<div class="o_setting_right_pane">
|
||||||
|
<label for="low_stock_threshold" string="Low Stock Threshold"/>
|
||||||
|
<div class="text-muted">
|
||||||
|
Products with stock ≤ this value show "Low Stock" ribbon. Stock = 0 shows "Out of Stock" ribbon.
|
||||||
|
</div>
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt16">
|
||||||
|
<field name="low_stock_threshold" class="oe_inline"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|
|
||||||
|
|
@ -1095,6 +1095,15 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
<!-- Stock Ribbon -->
|
||||||
|
<t t-if="product.dynamic_ribbon_id">
|
||||||
|
<t t-set="ribbon" t-value="product.dynamic_ribbon_id"/>
|
||||||
|
<span
|
||||||
|
t-attf-class="o_ribbon {{ ribbon._get_position_class() }} z-1"
|
||||||
|
t-attf-style="color: {{ ribbon.text_color }}; background-color: {{ ribbon.bg_color }};"
|
||||||
|
t-esc="ribbon.name"
|
||||||
|
/>
|
||||||
|
</t>
|
||||||
<div
|
<div
|
||||||
class="card-body d-flex flex-column"
|
class="card-body d-flex flex-column"
|
||||||
>
|
>
|
||||||
|
|
@ -1266,13 +1275,14 @@
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="add-to-cart-btn"
|
t-attf-class="add-to-cart-btn {{ 'btn-disabled' if product.is_out_of_stock else '' }}"
|
||||||
type="button"
|
type="button"
|
||||||
t-attf-aria-label="Add {{ product.name }} to cart"
|
t-attf-disabled="{{ 'disabled' if product.is_out_of_stock else '' }}"
|
||||||
t-attf-title="Add {{ product.name }} to cart"
|
t-attf-aria-label="{{ 'Out of stock' if product.is_out_of_stock else 'Add %s to cart' % product.name }}"
|
||||||
|
t-attf-title="{{ 'Out of stock' if product.is_out_of_stock else 'Add %s to cart' % product.name }}"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
class="fa fa-shopping-cart"
|
t-attf-class="fa {{ 'fa-ban' if product.is_out_of_stock else 'fa-shopping-cart' }}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue