Resumen rápido: En este artículo revisamos y documentamos una corrección integral para un problema común en sistemas de inventario: la generación de lotes duplicados al agregar productos rápidamente vía AJAX. Incluye la solución frontend con un contador en memoria, la validación backend de subcategoría, manejo de errores con Toast, ejemplos de código listos para copiar y todo explicado paso a paso. Además se referencia el commit donde se subieron los cambios.
🧭 Introducción — ¿qué problema resolvimos y por qué importa?
En aplicaciones web que manejan inventario, es frecuente tener un flujo donde el usuario busca un producto y presiona Agregar varias veces. Si ese evento dispara una petición AJAX al servidor para obtener el último lote disponible y la respuesta se procesa en paralelo, existe una ventana de inconsistencia: varias respuestas pueden leer el mismo estado inicial y generar el mismo consecutivo, produciendo lotes duplicados (por ejemplo LMPLMLAPTOP000001 repetido varias veces). Esto provoca problemas reales: registros duplicados en inventario, conflictos de seguimiento, problemas legales en trazabilidad, y mala experiencia de usuario.
La meta fue sencilla pero crítica: evitar duplicados y hacer el flujo robusto sin reescribir toda la arquitectura. Priorizamos una solución práctica, compatible con jQuery y CodeIgniter/PHP, que puedas aplicar hoy en tu proyecto.
🔎 Diagnóstico técnico — por qué ocurrieron los duplicados
El error tiene tres factores principales:
- Asincronía: las peticiones AJAX regresan en tiempos distintos.
- Dependencia del DOM: el código calculaba el siguiente consecutivo consultando elementos del DOM que todavía no reflejaban las inserciones hechas por otras respuestas.
- Falta de validación del backend: el backend asumía que existían datos (por ejemplo, la subcategoría), lo que podía llevar a lotes mal formados o a errores silenciosos.
La combinación de esos puntos genera la race condition: varias respuestas ven el mismo último consecutivo y todas calculan el mismo siguiente valor.
🛠️ Solución aplicada — enfoque general
Optamos por una solución híbrida y pragmática:
- Frontend: mantener un contador en memoria (
lotesContador) que almacene, por cadaloteBase, el último consecutivo usado en la sesión. Al recibir la respuesta del backend, sincronizamos ese contador con el consecutivo que trae el servidor y, si corresponde, lo incrementamos localmente para el siguiente lote. Esto evita depender del estado del DOM y evita conflictos cuando el usuario hace clicks rápidos. - Backend: agregar validaciones tempranas para condiciones críticas (por ejemplo: que el producto tenga subcategoría). Si falta información, el servidor devuelve una respuesta JSON con
error: truey unmessagedescriptivo. - UX: usar toasts para informar errores (icono de error), y evitar mensajes ambiguos o iconos de éxito cuando ocurrió un fallo.
💡 ¿Por qué esta solución es práctica?
Porque:
- No requiere reestructurar la base de datos ni añadir infraestructura adicional.
- Es fácil de integrar en proyectos existentes con jQuery + backend PHP/CodeIgniter.
- Reduce la ventana de inconsistencias en la interfaz del usuario y evita la mayoría de los duplicados en escenarios de click rápido.
- Permite seguir usando al backend como fuente de verdad cuando hay cambios externos: si el backend reporta un consecutivo mayor al local, el frontend se sincroniza.
🔧 Código: backend (CodeIgniter) — validar subcategoría y devolver JSON de error
Este fragmento se inserta en el controller que calcula el lote (método calculateLot() o similar). La idea es validar que exista idSubCategoria en la fila del producto y que la subcategoría realmente exista en la tabla de subcategorías. Si falla, devolvemos un JSON con error.
// Validar que tenga subcategoría
if (empty($productData["idSubCategoria"])) {
return $this->response->setJSON([
"error" => true,
"message" => "El producto no tiene subcategoría asignada"
]);
}
// Buscar subcategoría
$subCategoryData = $this->subCategory->select("*")
->where("id", $productData["idSubCategoria"])
->first();
// Validar que exista
if (!$subCategoryData) {
return $this->response->setJSON([
"error" => true,
"message" => "Subcategoría no encontrada"
]);
}
$keyCategory = $subCategoryData["descripcion"];
// ... continuar con la construcción de $baseLot, búsqueda del último lote y generación del consecutivo ...
Con esto, si alguien intenta calcular un lote para un producto sin subcategoría, el frontend recibirá un JSON con error y se podrá mostrar un mensaje al usuario en lugar de producirse un lote inválido o un fallo más profundo.
🖥️ Código: frontend (jQuery) — patrón robusto para decidir loteFinal
A continuación está el patrón que implementamos en el success del AJAX. Debes declarar var lotesContador = {}; en un scope compartido (por ejemplo, al inicio de tu archivo JS o dentro de tu $(function(){...})), y luego usar esta lógica al recibir la respuesta del servidor para calcular el loteFinal.
/* Declarar al inicio del script */
var lotesContador = {};
/* Dentro del success del AJAX */
if (respuesta.error) {
Toast.fire({
icon: 'error',
title: respuesta.message
});
return;
}
var loteCalculado = (respuesta && respuesta.lot) ? respuesta.lot : (lote || "UNKNOWN000001");
if (!loteCalculado) {
console.error("No se pudo determinar el lote calculado");
return;
}
var loteBase = loteCalculado.slice(0, -6); // asume 6 dígitos de consecutivo
var consecutivoBackend = parseInt(loteCalculado.slice(-6), 10);
if (isNaN(consecutivoBackend)) consecutivoBackend = 0;
var loteFinal;
if (!lotesContador.hasOwnProperty(loteBase)) {
// Primera vez: inicializar con lo que trae el backend
lotesContador[loteBase] = consecutivoBackend;
loteFinal = loteCalculado;
} else {
// Ya hay un contador local
if (consecutivoBackend > lotesContador[loteBase]) {
// Backend avanzó por fuera de esta sesión: sincronizamos
lotesContador[loteBase] = consecutivoBackend;
loteFinal = loteCalculado;
} else {
// Usamos el siguiente consecutivo en memoria
lotesContador[loteBase] = lotesContador[loteBase] + 1;
var nuevo = String(lotesContador[loteBase]).padStart(6, "0");
loteFinal = loteBase + nuevo;
}
}
/* Llamar a la función que agrega el renglón con loteFinal */
agregarRenglon( idProduct, codeProduct, loteFinal, description, salePrice,
porcentTax, porcentIVARetenido, porcentISRRetenido,
claveUnidadSAT, unidad, claveProductoSAT );
Este flujo asegura que:
- Si el backend ya avanzó el consecutivo en otra sesión, el frontend se sincroniza.
- Si no, el frontend incrementa localmente el consecutivo para evitar duplicados cuando se hagan clicks rápidos.
📋 Función agregarRenglon — ejemplo práctico
Aquí tienes una versión robusta de tu función que construye el HTML del renglón y normaliza valores numéricos para evitar NaN o inputs inválidos:
function agregarRenglon(idProduct, codeProduct, lote, description, salePrice,
porcentTax, porcentIVARetenido, porcentISRRetenido,
claveUnidadSAT, unidad, claveProductoSAT) {
salePrice = Number(salePrice) || 0;
porcentTax = Number(porcentTax) || 0;
porcentIVARetenido = Number(porcentIVARetenido) || 0;
porcentISRRetenido = Number(porcentISRRetenido) || 0;
var tax = (porcentTax > 0) ? ((porcentTax * 0.01) * salePrice) : 0;
var IVARetenido = (porcentIVARetenido > 0) ? ((porcentIVARetenido * 0.01) * salePrice) : 0;
var ISRRetenido = (porcentISRRetenido > 0) ? ((porcentISRRetenido * 0.01) * salePrice) : 0;
var neto = (((porcentTax * 0.01) + 1) * salePrice) - (IVARetenido + ISRRetenido);
var renglon = "<div class='form-group row nuevoProduct'>";
renglon += "<div class='col-1'>";
renglon += "<button type='button' class='btn btn-danger quitProduct'><span class='far fa-trash-alt'></span></button>";
renglon += " <button type='button' data-toggle='modal' data-target='#modelMoreInfoRow' class='btn btn-primary btnInfo'><span class='fa fa-fw fa-pencil-alt'></span></button> ";
renglon += "<input type='hidden' class='idProductR' name='idProductR' value='" + (idProduct || "") + "'>";
renglon += "</div>";
renglon += "<div class='col-1'>";
renglon += "<input type='hidden' class='claveProductoSATR' name='claveProductoSATR' value='" + (claveProductoSAT || "") + "'>";
renglon += "<input type='hidden' class='claveUnidadSatR' name='claveUnidadSatR' value='" + (claveUnidadSAT || "") + "'>";
renglon += "<input type='hidden' class='unidad' name='unidad' value='" + (unidad || "") + "'>";
renglon += "<input type='text' class='form-control codeProduct' name='codeProduct' value='" + (codeProduct || "") + "'> </div>";
renglon += "<div class='col-1'> <input type='text' class='form-control lote' name='lote' value='" + (lote || "") + "' required> </div>";
renglon += "<div class='col-6'> <input type='text' class='form-control description' name='description' value='" + (description || "") + "' required> </div>";
renglon += "<div class='col-1'> <input type='number' class='form-control cant' name='cant' value='1' required>";
renglon += "<input type='hidden' class='porcentIVARetenido' name='porcentIVARetenido' value='" + porcentIVARetenido + "'>";
renglon += "<input type='hidden' class='porcentISRRetenido' name='porcentISRRetenido' value='" + porcentISRRetenido + "'>";
renglon += "<input type='hidden' class='porcentTax' name='porcentTax' value='" + porcentTax + "'></div>";
renglon += "<div class='col-1'> <input type='number' class='form-control price' name='price' value='" + salePrice + "' required>";
renglon += "<input type='hidden' class='IVARetenido' name='IVARetenido' value='" + IVARetenido + "'>";
renglon += "<input type='hidden' class='ISRRetenido' name='ISRRetenido' value='" + ISRRetenido + "'>";
renglon += "<input type='hidden' class='tax' name='tax' value='" + tax + "'> </div>";
renglon += "<div class='col-1'> <input readonly type='number' class='form-control total' name='total' value='" + salePrice + "'>";
renglon += "<input type='hidden' class='neto' name='neto' value='" + neto + "'> </div>";
renglon += "</div>";
$('.rowProducts').append(renglon);
if (typeof listProducts === "function") listProducts();
}
✅ Manejo de errores y UX — toasts y mensajes claros
Una buena UX evita frustración. Cuando el backend devuelve error: true, mostramos un toast con icono de error y el mensaje específico:
if (respuesta.error) {
Toast.fire({
icon: 'error',
title: respuesta.message
});
return;
}
Observa dos puntos importantes:
- No uses
icon: 'success'para errores. - Mantén los mensajes del backend claros y útiles para el usuario (por ejemplo: “El producto no tiene subcategoría asignada”).
🧪 Pruebas que debes ejecutar (y por qué)
Antes de subir a producción, ejecuta una batería de pruebas que validen los casos reales:
- Clicks rápidos: simula spam click y verifica que los lotes generados en el DOM sean secuenciales y no repetidos.
- Producto sin subcategoría: intenta agregar un producto con
idSubCategoriavacío o inexistente y verifica que aparezca el toast con el mensaje correcto y que no se agregue el renglón. - Multiusuario: abre dos sesiones diferentes (o dos navegadores) y agrega productos con la misma
loteBasepara comprobar la sincronización con el backend. - Eliminar renglón: borra renglones y agrega nuevos; el contador en memoria no se decrementa (esto evita reuso accidental de consecutivos), verifica que los nuevos lotes aumenten correctamente.
- Prueba de estrés: con un pequeño script o con herramientas como
k6oab, simula múltiples peticiones al endpointgetLastLoty verifica que el backend responda correctamente y que el frontend se comporte como esperado.
⚠️ Consideraciones y limitaciones de la solución
La solución de contador en memoria es práctica y resuelve el problema en la mayoría de los casos, pero existen condiciones donde se requiere mayor robustez:
- Escalabilidad y clusters distribuidos: si tu aplicación corre en múltiples servidores y hay concurrente masiva, el contador en memoria por sesión no garantiza unicidad global. En ese caso, considera un servicio centralizado (Redis, tabla con contador atómico) para emitir consecutivos atómicos.
- Transacciones críticas: si la generación del lote debe ser 100% atómica con la inserción del registro en inventario, la lógica debe moverse al backend con locking o transacción que reserve el siguiente número y lo persista en la misma operación.
- Reutilización de números: en esta solución NO se reutilizan consecutivos al borrar renglones. Reusar números es peligroso porque puede llevar a duplicados e inconsistencias en auditoría. Si requieres reuso, debe implementarse cuidadosamente en el backend y con auditoría.
🔁 Alternativas y mejoras (sin la sección “Siguiente paso”)
Aquí describimos varias alternativas que puedes implementar según tu nivel de exigencia:
1) Redis / contador centralizado
Usar INCR en Redis para obtener el siguiente consecutivo de forma atómica. Ideal para arquitecturas distribuidas. Ventajas: simple y rápido. Inconveniente: requiere infra adicional.
2) Tabla en BD con fila de contador y SELECT FOR UPDATE
Usar una tabla con el contador y reservar el número con locking a nivel de transacción. Ventajas: no necesitas nueva infra; Inconveniente: puede ser más lento bajo alta concurrencia y requiere diseño cuidadoso para evitar contención.
3) Generación optimista y reconciliación
Generas un lote provisional en frontend y al persistir en backend verificas si está disponible. Si no, el backend retorna el lote correcto y el frontend actualiza el renglón. Esto requiere un flujo UX para reconciliación (indicar “lote pendiente” y luego “confirmado”).
📦 Registro de cambios — commit en tu repositorio
Los cambios relacionados con esta corrección se subieron al repositorio en el commit referenciado a continuación. Revisa el diff para ver exactamente qué archivos y líneas se modificaron.
Commit con los cambios: :contentReference[oaicite:0]{index=0}
📌 Checklist completo para despliegue (rápido)
- ✅ Aplicar validación de
idSubCategoriaen backend. - ✅ Devolver
error:trueymessagesi falta subcategoría. - ✅ Implementar
lotesContadoren frontend y sincronizar con backend. - ✅ Mostrar Toast con
icon: 'error'cuando haya problemas. - ✅ Ejecutar pruebas de clicks rápidos y multiusuario.
- ✅ Instrumentar logs en backend para cada lote generado (auditoría).
- ✅ No decrementar el contador local al borrar renglones.
- ✅ Revisar la necesidad de un contador global (Redis/BD) según volumen de concurrencia.
🔍 Ejemplos de mensajes útiles para el backend
El producto no tiene subcategoría asignada— cuandoidSubCategoriaestá vacío.Subcategoría no encontrada— cuando el ID no existe en la tabla de subcategorías.No se pudo obtener el último lote— cuando la consulta a la tabla de saldos falla.
Mensajes claros hacen más fácil el debugging y mejoran la experiencia del desarrollador y del usuario final.
🧾 Buenas prácticas y recomendaciones finales
- Fail fast: valida temprano en backend y devuelve errores claros.
- Fuente de verdad: el backend siempre es la fuente de verdad; el frontend optimiza UX y reduce latencia.
- Auditoría: registra cada vez que se asigna un lote para trazar operaciones en caso de discrepancias.
- Pruebas automáticas: escribe pruebas e2e que cubran clicks rápidos y multiusuario.
- Documentación: documenta en el README del repo el comportamiento del contador en memoria y las condiciones que requieren un contador centralizado.
🔚 Conclusión
Resolver la duplicación de lotes en un sistema de inventario es crítico para mantener la integridad de los datos y la confianza del usuario. La estrategia que aplicamos —validación temprana en backend y un contador en memoria sincronizado en frontend— ofrece una solución práctica, rápida de implementar y efectiva para la gran mayoría de escenarios. Para entornos de alta concurrencia o arquitecturas distribuidas, existe la opción de aumentar la robustez con contadores atómicos centralizados (Redis, BD con locking).
Si quieres que convierta esta guía en un artículo formateado para tu tema de WordPress (por ejemplo con estilos específicos o bloques personalizados), o que genere una versión en Markdown para tu repo, lo preparo. Solo pégalo en un bloque HTML personalizado y debería quedar correcto en Gutenberg.
Artículo generado para facilitar la integración de correcciones en el módulo de inventario. Copia y pega en tu editor de WordPress (bloque HTML personalizado) y listo. ✅
Deja un comentario