Compare commits

...

3 commits

Author SHA1 Message Date
snt
3eeca66551 [FIX] stock_picking_batch_custom: prevent product_id null error on summary lines
- Use regular dict instead of defaultdict to avoid empty entries
- Make summary_line_ids readonly=True to prevent UI from inserting empty lines
- Add SQL constraint CHECK(product_id IS NOT NULL) as safeguard
- Use boolean_toggle widget for is_collected field
- Fix tests to use TransactionCase and invalidate_recordset
- Add test for empty batch + add pickings + confirm flow
2026-03-05 21:47:18 +01:00
snt
ad8b759643 [ADD] stock_picking_batch_custom: product summary 2026-03-05 20:29:17 +01:00
snt
9c14e1dc1a [FIX] website_sale_aplicoop: ensure add-to-cart on infinite scroll 2026-03-05 18:57:10 +01:00
13 changed files with 690 additions and 80 deletions

View file

@ -1,13 +1,66 @@
=============================== ============================
Stock Picking Batch Custom Stock Picking Batch Custom
=============================== ============================
.. contents:: Visión general
:local: ==============
Este módulo amplía las operaciones detalladas y añade un resumen por producto
en los lotes de picking:
.. include:: readme/DESCRIPTION.rst - ``picking_partner_id`` (Partner del albarán) para identificar cliente/proveedor.
.. include:: readme/INSTALL.rst - ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
.. include:: readme/CONFIGURE.rst - ``is_collected`` (Recogido) como check manual en cada línea para marcar si se ha
.. include:: readme/USAGE.rst recolectado.
.. include:: readme/CONTRIBUTORS.rst - Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
.. include:: readme/CREDITS.rst pendiente), categoría y el check de recogido consolidado.
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
Configuración
=============
No requiere configuración adicional. Para usar las columnas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar *Partner*, *Product Category* y *Collected* según necesidad.
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.
Contribuidores
==============
* Criptomart
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -13,6 +13,8 @@
"stock_picking_batch", "stock_picking_batch",
], ],
"data": [ "data": [
"security/ir.model.access.csv",
"views/stock_move_line_views.xml", "views/stock_move_line_views.xml",
"views/stock_picking_batch_views.xml",
], ],
} }

View file

@ -1 +1,2 @@
from . import stock_move_line # noqa: F401 from . import stock_move_line # noqa: F401
from . import stock_picking_batch # noqa: F401

View file

@ -10,8 +10,14 @@ class StockMoveLine(models.Model):
product_categ_id = fields.Many2one( product_categ_id = fields.Many2one(
comodel_name="product.category", comodel_name="product.category",
string="Product Category", string="Product Category (Batch)",
related="product_id.categ_id", related="product_id.categ_id",
store=True, store=True,
readonly=True, readonly=True,
) )
is_collected = fields.Boolean(
string="Collected",
default=False,
copy=False,
)

View file

@ -0,0 +1,202 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import fields
from odoo import models
class StockPickingBatch(models.Model):
_inherit = "stock.picking.batch"
summary_line_ids = fields.One2many(
comodel_name="stock.picking.batch.summary.line",
inverse_name="batch_id",
string="Product Summary",
compute="_compute_summary_line_ids",
store=True,
readonly=True,
)
@api.depends(
"move_line_ids.move_id.product_id",
"move_line_ids.move_id.product_uom_qty",
"move_line_ids.move_id.quantity",
"move_line_ids.move_id.product_uom",
"move_line_ids.move_id.state",
)
def _compute_summary_line_ids(self):
"""Aggregate move quantities per product and keep collected flag.
- Demand: move.product_uom_qty converted to product UoM.
- Done: move.quantity converted to product UoM.
- Pending: demand - done.
- Keep is_collected value if product already present.
- Skip cancelled moves.
"""
for batch in self:
# Base: limpiar si no hay líneas
if not batch.move_line_ids:
batch.summary_line_ids = [fields.Command.clear()]
continue
previous_collected = {
line.product_id.id: line.is_collected
for line in batch.summary_line_ids
if line.product_id
}
# Use regular dict to avoid accidental creation of empty entries
aggregates = {}
# Demand per move (count once per move)
for move in batch.move_ids:
if move.state == "cancel" or not move.product_id:
continue
product = move.product_id
product_uom = product.uom_id
demand = move.product_uom._compute_quantity(
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
)
if product.id not in aggregates:
aggregates[product.id] = {
"product_id": product.id,
"product_uom_id": product_uom.id,
"qty_demanded": 0.0,
"qty_done": 0.0,
}
aggregates[product.id]["qty_demanded"] += demand
# Done per move line
for line in batch.move_line_ids:
move = line.move_id
if move and move.state == "cancel":
continue
product = line.product_id
if not product:
continue
product_uom = product.uom_id
done = line.product_uom_id._compute_quantity(
line.quantity, product_uom, rounding_method="HALF-UP"
)
if product.id not in aggregates:
aggregates[product.id] = {
"product_id": product.id,
"product_uom_id": product_uom.id,
"qty_demanded": 0.0,
"qty_done": 0.0,
}
aggregates[product.id]["qty_done"] += done
commands = []
products_in_totals = set()
existing_by_product = {
line.product_id.id: line for line in batch.summary_line_ids
}
for product_id, values in aggregates.items():
# Double-check: skip if product_id is not valid
if not product_id or not values.get("product_id"):
continue
products_in_totals.add(product_id)
existing_line = existing_by_product.get(product_id)
is_collected = previous_collected.get(product_id, False)
if existing_line:
commands.append(
fields.Command.update(
existing_line.id,
{
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
"is_collected": is_collected,
},
)
)
else:
# Ensure all required fields are present before create
if values["product_id"] and values["product_uom_id"]:
commands.append(
fields.Command.create(
{
"product_id": values["product_id"],
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
"is_collected": is_collected,
}
)
)
obsolete_lines = [
fields.Command.unlink(line.id)
for line in batch.summary_line_ids
if line.product_id.id not in products_in_totals
]
if commands or obsolete_lines:
batch.summary_line_ids = commands + obsolete_lines
else:
batch.summary_line_ids = [fields.Command.clear()]
class StockPickingBatchSummaryLine(models.Model):
_name = "stock.picking.batch.summary.line"
_description = "Batch Product Summary Line"
_order = "product_categ_id, product_id"
_sql_constraints = [
(
"product_required",
"CHECK(product_id IS NOT NULL)",
"Product is required for summary lines.",
),
]
batch_id = fields.Many2one(
comodel_name="stock.picking.batch",
ondelete="cascade",
required=True,
index=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
required=True,
index=True,
)
product_categ_id = fields.Many2one(
comodel_name="product.category",
string="Product Category",
related="product_id.categ_id",
store=True,
readonly=True,
)
product_uom_id = fields.Many2one(
comodel_name="uom.uom",
string="Unit of Measure",
required=True,
)
qty_demanded = fields.Float(
string="Demanded Quantity",
digits="Product Unit of Measure",
required=True,
default=0.0,
)
qty_done = fields.Float(
string="Done Quantity",
digits="Product Unit of Measure",
required=True,
default=0.0,
)
qty_pending = fields.Float(
string="Pending Quantity",
digits="Product Unit of Measure",
compute="_compute_qty_pending",
store=True,
)
is_collected = fields.Boolean(string="Collected", default=False)
@api.depends("qty_demanded", "qty_done")
def _compute_qty_pending(self):
for line in self:
line.qty_pending = line.qty_demanded - line.qty_done

View file

@ -1,11 +1,14 @@
Este módulo añade dos columnas opcionales en las operaciones detalladas de los Este módulo amplía las operaciones detalladas y añade un resumen por producto
lotes de picking: en los lotes de picking:
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén - ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
pueda identificar rápidamente el cliente/proveedor asociado. identifique rápido el cliente/proveedor.
- ``product_categ_id`` (Categoría de producto) para permitir ordenación y - ``product_categ_id`` (Categoría de producto) para ordenar y agrupar.
agrupación por categoría. - ``is_collected`` (Recogido) como check manual en cada línea para marcar si se
ha recolectado.
- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho,
pendiente), categoría y el check de recogido consolidado.
Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote,
lote, de modo que el usuario puede activarlas desde el selector de columnas sin de modo que el usuario puede activarlas desde el selector de columnas sin
cargar la vista por defecto. recargar la vista por defecto.

View file

@ -6,5 +6,9 @@ Uso
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor. - **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría. - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
- **Collected** (``is_collected``) para marcar manualmente líneas recolectadas.
3. Ordena o agrupa por la columna de categoría según convenga. 3. Pestaña **Product Summary**: consulta los totales por producto (demandado,
hecho y pendiente) y marca el check de recogido consolidado si corresponde.
4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga.

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_picking_batch_summary_line_user,stock_picking_batch_summary_line_user,model_stock_picking_batch_summary_line,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_picking_batch_summary_line_user stock_picking_batch_summary_line_user model_stock_picking_batch_summary_line base.group_user 1 1 1 0

View file

@ -0,0 +1 @@
from . import test_batch_summary # noqa: F401

View file

@ -0,0 +1,289 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("-at_install", "post_install")
class TestBatchSummary(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
cls.location_src = cls.env.ref("stock.stock_location_stock")
cls.location_dest = cls.env.ref("stock.stock_location_customers")
cls.picking_type = cls.env.ref("stock.picking_type_out")
cls.product = cls.env["product.product"].create(
{
"name": "Test Product",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
}
)
cls.batch = cls.env["stock.picking.batch"].create(
{"name": "Batch Test", "picking_type_id": cls.picking_type.id}
)
cls.picking1 = cls.env["stock.picking"].create(
{
"picking_type_id": cls.picking_type.id,
"location_id": cls.location_src.id,
"location_dest_id": cls.location_dest.id,
"batch_id": cls.batch.id,
}
)
cls.picking2 = cls.env["stock.picking"].create(
{
"picking_type_id": cls.picking_type.id,
"location_id": cls.location_src.id,
"location_dest_id": cls.location_dest.id,
"batch_id": cls.batch.id,
}
)
# Helpers
def _add_move(self, picking, qty_demanded, qty_done, uom):
move = self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": qty_demanded,
"product_uom": uom.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
if qty_done:
self.env["stock.move.line"].create(
{
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
"product_uom_id": self.product.uom_id.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": qty_done,
}
)
return move
def _recompute_batch(self):
self.batch.invalidate_recordset()
self.batch._compute_summary_line_ids()
def _create_batch_with_pickings(self):
batch = self.env["stock.picking.batch"].create(
{"name": "Batch Flow", "picking_type_id": self.picking_type.id}
)
picking = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
move = self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 2.0,
"product_uom": self.uom_unit.id,
"picking_id": picking.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move.line"].create(
{
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
"product_uom_id": self.uom_unit.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": 2.0,
}
)
picking.batch_id = batch.id
return batch
# Tests
def test_totals_and_pending_with_conversion(self):
"""Totals aggregate per product with UoM conversion and pending."""
# demand 12 units, done 5 units
self._add_move(self.picking1, qty_demanded=12, qty_done=5, uom=self.uom_unit)
# demand 2 dozens (24 units), done 6 units
self._add_move(self.picking2, qty_demanded=2, qty_done=6, uom=self.uom_dozen)
self._recompute_batch()
self.assertEqual(len(self.batch.summary_line_ids), 1)
line = self.batch.summary_line_ids
self.assertEqual(line.product_id, self.product)
self.assertEqual(line.product_uom_id, self.uom_unit)
self.assertAlmostEqual(line.qty_demanded, 36.0)
self.assertAlmostEqual(line.qty_done, 11.0)
self.assertAlmostEqual(line.qty_pending, 25.0)
def test_collected_flag_preserved_on_recompute(self):
"""Collected stays checked after totals change."""
self._add_move(self.picking1, qty_demanded=1, qty_done=1, uom=self.uom_unit)
self._recompute_batch()
line = self.batch.summary_line_ids
line.is_collected = True
# Add more demand/done to trigger an update
self._add_move(self.picking1, qty_demanded=3, qty_done=2, uom=self.uom_unit)
self._recompute_batch()
self.assertTrue(self.batch.summary_line_ids.is_collected)
def test_cancelled_moves_are_ignored(self):
"""Cancelled moves do not count in the summary and lines are removed."""
move1 = self._add_move(
self.picking1, qty_demanded=4, qty_done=2, uom=self.uom_unit
)
move2 = self._add_move(
self.picking2, qty_demanded=6, qty_done=3, uom=self.uom_unit
)
self._recompute_batch()
self.assertEqual(len(self.batch.summary_line_ids), 1)
move1.write({"state": "cancel"})
move2.write({"state": "cancel"})
self._recompute_batch()
self.assertFalse(self.batch.summary_line_ids)
def test_no_required_product_error_on_confirm(self):
"""Confirming batch with pickings must not create summary lines without product."""
batch = self._create_batch_with_pickings()
# Trigger compute via confirm flow
batch.action_confirm()
self.assertTrue(batch.summary_line_ids)
self.assertFalse(
batch.summary_line_ids.filtered(lambda line: not line.product_id)
)
def test_empty_batch_add_pickings_then_confirm(self):
"""Create empty draft batch, add multiple pickings, then confirm."""
# 1. Create empty batch in draft state
batch = self.env["stock.picking.batch"].create(
{"name": "Batch Empty Start", "picking_type_id": self.picking_type.id}
)
self.assertEqual(batch.state, "draft")
self.assertFalse(batch.picking_ids)
self.assertFalse(batch.summary_line_ids)
# 2. Create pickings with moves (without batch assignment)
product2 = self.env["product.product"].create(
{
"name": "Test Product 2",
"uom_id": self.uom_unit.id,
"uom_po_id": self.uom_unit.id,
}
)
picking_a = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 5.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_a.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
picking_b = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": product2.name,
"product_id": product2.id,
"product_uom_qty": 10.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_b.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
picking_c = self.env["stock.picking"].create(
{
"picking_type_id": self.picking_type.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
self.env["stock.move"].create(
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 3.0,
"product_uom": self.uom_unit.id,
"picking_id": picking_c.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
}
)
# 3. Add pickings to the batch
picking_a.batch_id = batch
picking_b.batch_id = batch
picking_c.batch_id = batch
self.assertEqual(len(batch.picking_ids), 3)
# 4. Confirm the batch — this should not raise product_id required error
batch.action_confirm()
self.assertEqual(batch.state, "in_progress")
# 5. Verify summary lines are correct
self.assertTrue(batch.summary_line_ids)
self.assertFalse(
batch.summary_line_ids.filtered(lambda line: not line.product_id)
)
# Two products expected
products_in_summary = batch.summary_line_ids.mapped("product_id")
self.assertIn(self.product, products_in_summary)
self.assertIn(product2, products_in_summary)
# Check aggregated quantities
line_product1 = batch.summary_line_ids.filtered(
lambda line: line.product_id == self.product
)
self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
line_product2 = batch.summary_line_ids.filtered(
lambda line: line.product_id == product2
)
self.assertAlmostEqual(line_product2.qty_demanded, 10.0)

View file

@ -11,6 +11,9 @@
<xpath expr="//field[@name='picking_id']" position="after"> <xpath expr="//field[@name='picking_id']" position="after">
<field name="picking_partner_id" optional="hide"/> <field name="picking_partner_id" optional="hide"/>
</xpath> </xpath>
<xpath expr="//field[@name='quantity']" position="after">
<field name="is_collected" optional="show" widget="boolean_toggle"/>
</xpath>
</field> </field>
</record> </record>
</odoo> </odoo>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_picking_batch_summary_line_tree" model="ir.ui.view">
<field name="name">stock.picking.batch.summary.line.tree</field>
<field name="model">stock.picking.batch.summary.line</field>
<field name="arch" type="xml">
<list create="0" delete="0" default_order="product_categ_id,product_id">
<field name="product_id" readonly="1"/>
<field name="product_categ_id" readonly="1" optional="hide"/>
<field name="product_uom_id" readonly="1" optional="hide"/>
<field name="qty_demanded" readonly="1"/>
<field name="qty_done" readonly="1"/>
<field name="qty_pending" readonly="1"/>
<field name="is_collected" widget="boolean_toggle" nolabel="1"/>
</list>
</field>
</record>
<record id="view_stock_picking_batch_form_inherit_summary" model="ir.ui.view">
<field name="name">stock.picking.batch.form.summary</field>
<field name="model">stock.picking.batch</field>
<field name="inherit_id" ref="stock_picking_batch.stock_picking_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook/page[@name='page_detailed_operations']" position="after">
<page string="Product Summary" name="page_product_summary">
<field name="summary_line_ids" context="{'tree_view_ref': 'stock_picking_batch_custom.view_stock_picking_batch_summary_line_tree'}"/>
</page>
</xpath>
</field>
</record>
</odoo>

View file

@ -694,6 +694,70 @@
return; return;
} }
// Shared handler for add-to-cart to reuse across grid/document listeners
var handleAddToCart = function (e) {
var cartBtn = e.target.closest(".add-to-cart-btn");
if (!cartBtn) return;
e.preventDefault();
var form = cartBtn.closest(".add-to-cart-form");
if (!form) return;
var productId = form.getAttribute("data-product-id");
var productName = form.getAttribute("data-product-name") || "Product";
var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0;
var quantityInput = form.querySelector(".product-qty");
var quantity = quantityInput ? parseFloat(quantityInput.value) : 1;
// Block add-to-cart if product is flagged out of stock (from template)
var isOutOfStock =
(form.getAttribute("data-out-of-stock") || "false") === "true" ||
(cartBtn.getAttribute("data-out-of-stock") || "false") === "true";
// Fallback guards in case cached markup drops the data attribute
if (!isOutOfStock) {
var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase();
var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase();
var iconEl = cartBtn.querySelector("i");
var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban");
if (
hasBanIcon ||
btnTitle.includes("out of stock") ||
btnTitle.includes("sin stock") ||
btnAria.includes("out of stock") ||
btnAria.includes("sin stock")
) {
isOutOfStock = true;
}
}
if (isOutOfStock) {
var labels = self._getLabels();
self._showNotification(
labels.out_of_stock || "Product is out of stock",
"warning"
);
return;
}
console.log("Adding:", {
productId: productId,
productName: productName,
productPrice: productPrice,
quantity: quantity,
});
if (quantity > 0) {
self._addToCart(productId, productName, productPrice, quantity);
} else {
var labels2 = self._getLabels();
self._showNotification(
labels2.invalid_quantity || "Please enter a valid quantity",
"warning"
);
}
};
// First, adjust quantity steps for all existing inputs // First, adjust quantity steps for all existing inputs
var unitInputs = document.querySelectorAll(".product-qty"); var unitInputs = document.querySelectorAll(".product-qty");
console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ==="); console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ===");
@ -779,67 +843,16 @@
} }
}); });
// Add to cart button (via event delegation) // Add to cart button (via event delegation on grid)
productsGrid.addEventListener("click", function (e) { productsGrid.addEventListener("click", handleAddToCart);
var cartBtn = e.target.closest(".add-to-cart-btn");
if (!cartBtn) return;
e.preventDefault(); // Also attach a document-level delegation as fallback for dynamically
var form = cartBtn.closest(".add-to-cart-form"); // inserted products (infinite scroll) in case the grid listener is lost
var productId = form.getAttribute("data-product-id"); // after DOM replacement.
var productName = form.getAttribute("data-product-name") || "Product"; if (!this._docCartListenerAttached) {
var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0; document.addEventListener("click", handleAddToCart, true);
var quantityInput = form.querySelector(".product-qty"); this._docCartListenerAttached = true;
var quantity = quantityInput ? parseFloat(quantityInput.value) : 1; }
// Block add-to-cart if product is flagged out of stock (from template)
var isOutOfStock =
(form.getAttribute("data-out-of-stock") || "false") === "true" ||
(cartBtn.getAttribute("data-out-of-stock") || "false") === "true";
// Fallback guards in case cached markup drops the data attribute
if (!isOutOfStock) {
var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase();
var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase();
var iconEl = cartBtn.querySelector("i");
var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban");
if (
hasBanIcon ||
btnTitle.includes("out of stock") ||
btnTitle.includes("sin stock") ||
btnAria.includes("out of stock") ||
btnAria.includes("sin stock")
) {
isOutOfStock = true;
}
}
if (isOutOfStock) {
var labels = self._getLabels();
self._showNotification(
labels.out_of_stock || "Product is out of stock",
"warning"
);
return;
}
console.log("Adding:", {
productId: productId,
productName: productName,
productPrice: productPrice,
quantity: quantity,
});
if (quantity > 0) {
self._addToCart(productId, productName, productPrice, quantity);
} else {
var labels = self._getLabels();
self._showNotification(
labels.invalid_quantity || "Please enter a valid quantity",
"warning"
);
}
});
}, },
_addToCart: function (productId, productName, productPrice, quantity) { _addToCart: function (productId, productName, productPrice, quantity) {