Blog
7 min de lectura

466 productos, 557 lotes, 0 errores: sincronizamos un ERP con una app web en tiempo real

Un cliente nos dijo: 'Tenemos todo en Holded pero necesitamos un sistema propio de inventario'. Spoiler: la API de Holded no funciona como dice su documentación. Esto es lo que aprendimos.

API RESTERPInventarioNext.jsSupabaseSincronización
𝕏in

Un cliente nos contactó con una necesidad concreta:

"Llevamos toda la facturación en Holded, pero para el inventario necesitamos algo que Holded no puede hacer. ¿Podéis construir algo que se conecte?"

La respuesta fue sí. Pero "conectar" un ERP con una app web a medida es como montar IKEA sin instrucciones: las piezas existen, pero las sorpresas están en los detalles.

Esta es la historia de cómo sincronizamos 466 productos, 195 proveedores y 557 lotes entre un ERP y una aplicación web — en tiempo real, sin perder un solo dato.

466 🔄 557

Productos y lotes sincronizados bidireccionalmente.
0 errores. 0 duplicados. 0 datos perdidos.

El punto de partida: dos mundos que no se hablan

El cliente es una distribuidora de dispositivos médicos. Cada producto lleva trazabilidad obligatoria: número de lote, fecha de caducidad, proveedor de origen. Normativa europea, sin atajos.

Usaban un ERP para facturación y contabilidad. Funcionaba bien para eso. Pero para gestión de inventario con lotes, escaneo rápido y trazabilidad sanitaria, se quedaba corto.

Necesitaban un sistema propio que:

1
Importara el catálogo completo — productos, proveedores y lotes ya existentes en el ERP.
2
Mantuviera ambos sistemas sincronizados — cambios en uno se reflejan en el otro automáticamente.
3
No rompiera la facturación — el ERP sigue siendo la fuente de verdad para contabilidad.

Suena razonable. "Solo hay que usar la API". ¿Verdad?

La realidad de las APIs de ERPs

Todas las APIs de ERPs tienen documentación. Algunas incluso tienen documentación correcta. La del ERP de este cliente no era una de ellas.

Estas son algunas de las trampas reales que encontramos — y que perdimos horas descubriendo:

1
Campos con nombres distintos en GET y POST — el campo que lees como lots lo envías como variants. Sin aviso.
2
Tipos de datos inconsistentes — un ID de proveedor puede ser un string, un string vacío, o un objeto {"$oid": "..."}. El mismo campo. La misma API.
3
Campos inmutables sin documentar — ciertos campos del producto no se pueden cambiar con PUT. Hay que borrar y recrear el producto entero.
4
Stock con deltas, no absolutos — para ajustar stock envías la diferencia, no el valor final. Enviar "+5" cuando querías decir "son 5" duplica tu inventario.
5
Timestamps en formato Unix… o 0 — las fechas de creación de algunos lotes llegan como 0. Literalmente. Cero.

Cada una de estas trampas es un bug en producción esperando a ocurrir. Y ninguna está en la documentación oficial.

⚠️

La regla de oro de las integraciones

Nunca confíes en la documentación de una API externa al 100%. Siempre valida con datos reales antes de escribir la primera línea de código. Los campos opcionales a veces son obligatorios, los tipos cambian entre endpoints, y los errores silenciosos son la norma.

Cómo lo resolvimos (sin perder la cabeza)

La clave fue diseñar la importación como un pipeline de 4 pasos secuenciales, cada uno validado antes de pasar al siguiente:

1
Proveedores
Importar contactos tipo proveedor → tabla local con holded_id
195 ✓
2
Productos
Sincronizar catálogo completo → vincular con proveedores importados
466 ✓
3
Activar lotes
Convertir productos simples a tipo "lotes" en el ERP (no editable por API)
132 ✓
4
Importar lotes
Traer cada lote con su número, caducidad, stock y fecha de entrada
557 ✓

Cada paso se ejecuta con streaming en tiempo real — el usuario ve una barra de progreso avanzando producto por producto. Nada de "espere 3 minutos mirando un spinner".

// 🔄 Streaming SSE — progreso en tiempo real
const stream = new ReadableStream({
  async start(controller) {
    for (const product of products) {
      await syncProduct(product)          // 💾 Sync individual
      sendProgress(controller, i, total)  // 📊 Notifica al cliente
    }
    controller.close()                    // ✅ Fin del stream
  }
})

El cliente ve exactamente qué está pasando en cada momento. Si algo falla en el producto 312 de 466, lo sabe al instante — no después de esperar a que termine todo.

El truco que evitó duplicados

El mayor riesgo en una sincronización bidireccional es crear duplicados. Un producto importado dos veces. Un lote que se cuenta doble. Un proveedor que aparece tres veces con nombres ligeramente distintos.

La solución: cada registro local guarda el ID del ERP. Antes de crear, siempre buscamos primero.

// 🔍 Upsert inteligente — nunca duplica
const existing = await db
  .from("products")
  .select("id")
  .eq("erp_id", erpProduct.id)  // 🆔 ¿Ya lo tenemos?
  .maybeSingle()

if (existing) {
  await db.from("products")
    .update(mapped)              // ♻️ Actualiza
    .eq("id", existing.id)
} else {
  await db.from("products")
    .insert(mapped)              // 🆕 Crea
}

Es un patrón simple, pero la diferencia entre implementarlo bien e implementarlo mal es la diferencia entre un sistema fiable y una pesadilla de datos corruptos.

La sincronización continua: el cron que vigila

La importación inicial es solo el principio. Después, ambos sistemas necesitan mantenerse sincronizados. Nuevos productos, lotes que se agotan, proveedores que cambian de nombre.

Implementamos un cron job cada 15 minutos que consulta los cambios en el ERP y los sincroniza automáticamente. Sin intervención manual. Sin botones que pulsar.

Frecuencia de sync
15 min
cron automático
Dirección
bidireccional
Datos perdidos
0
tras semanas en producción
Duplicados
0
gracias al upsert por ID

Lo que NO hicimos

❌ No hicimos
  • Usar un middleware genérico tipo Zapier
  • Hacer CSV exports manuales
  • Duplicar la lógica de facturación
  • Confiar ciegamente en la documentación
  • Sincronizar todo de golpe en un batch
✅ Sí hicimos
  • Integración directa API → Base de datos
  • Normalización de datos en cada dirección
  • Streaming con progreso en tiempo real
  • Validación exhaustiva campo por campo
  • Cron de polling cada 15 minutos

La diferencia entre Zapier y una integración custom es la diferencia entre un puente colgante y el Golden Gate. El primero funciona… hasta que no. El segundo aguanta terremotos.

Lo que aprendimos

💡 Lecciones de una integración real

Siempre castea los datos antes de comparar. Un campo "string" puede llegar como número, como objeto, o como vacío.
El upsert por ID externo es tu mejor amigo. Idempotencia = tranquilidad.
No sincronices todo de golpe. Pipeline secuencial: primero proveedores, luego productos, luego el detalle.
El usuario necesita ver qué está pasando. Streaming > spinner. Siempre.
Los cron jobs de sync automático son el 20% del esfuerzo que da el 80% del valor al cliente.

TL;DR

Un cliente necesitaba inventario con trazabilidad sanitaria conectado a su ERP de facturación. No existe un plugin para eso.

Construimos una integración bidireccional a medida que importó el catálogo completo (466 productos, 195 proveedores, 557 lotes) y mantiene ambos sistemas sincronizados cada 15 minutos — sin duplicados, sin datos perdidos, con progreso en tiempo real.

Las APIs de ERPs mienten en su documentación. Pero si sabes dónde mirar, puedes hacer que cualquier sistema hable con cualquier otro.

🛠️ Stack utilizado en este proyecto

Next.js 16 React 19 TypeScript Supabase API REST Server-Sent Events Vercel Cron PostgreSQL + RLS

¿Necesitas conectar tu ERP con un sistema propio? En Nexus Code desarrollamos integraciones a medida con cualquier ERP: Holded, Odoo, SAP, Sage, A3. Sin datos perdidos, sin duplicados. Cuéntanos tu caso y te decimos cómo lo haríamos.

¿Te ha resultado útil?

4.5 / 5 — 12 valoraciones

¿Necesitas conectar tu ERP con un sistema propio?

Integramos Holded, Odoo, SAP, Sage y cualquier ERP con tu aplicación web a medida. Sin datos perdidos, sin duplicados.

Cuéntanos tu proyecto