Saltar al contenido
VertS background

Full Stack · Logística Internacional · SaaS Multi-tenant

Torre de Control para Operaciones 3PL

De Excel y WhatsApp a un sistema que gestionó 400+ paquetes y 120+ salidas internacionales en los primeros 90 días de producción, bajo un piloto autorizado de validación operativa

Diseño: Nov 2025 — Producción: Feb 2026

400+
Paquetes procesados
120+
Salidas internacionales
90
Días en producción
~20min
Por instrucción de salida

#El problema que nadie quería nombrar

Cuando entré a trabajar en una empresa 3PL con operaciones desde Irlanda y Estados Unidos hacia todo el continente americano, la operación se manejaba así:

Excel para registrar tickets de recepción
Correo electrónico para coordinar entre bodega, clientes y equipo interno
WhatsApp para resolver urgencias
Carpetas en el escritorio para guardar fotos de las bodegas tercerizadas
Una pizarra con marcadores frente al escritorio de la directora de operaciones

Una instrucción de salida —consolidar los tickets listos, verificar la carga, preparar la documentación— tomaba entre 2 y 3 horas de trabajo manual. Los errores eran frecuentes y costosos: carga enviada al país equivocado, paquetes perdidos en el flujo, facturas mal calculadas.

#La decisión

Había un ERP en uso. Nadie lo usaba. Era demasiado complejo para una operación que no necesitaba un ERP genérico — necesitaba una herramienta construida a medida, que siguiera el flujo exacto de la empresa: sus nomenclaturas, sus etapas, sus reglas de negocio. No adaptar la operación al software, sino el software a la operación.

No busqué otro software. Decidí construirlo. Lo que la operación necesitaba era una herramienta que siguiera exactamente su flujo, suficientemente simple para que todo el equipo la adoptara, y que pusiera toda la información en un solo lugar en tiempo real. Empecé a diseñarla en noviembre de 2025. Entró en producción en febrero de 2026.

#El flujo completo en una vista

VertS gestiona el ciclo completo de una operación logística internacional: desde que el cliente avisa que tiene carga, hasta que esa carga llega al destinatario y se registra el costo del proveedor.

📋

01 / 10

Cliente crea Pre-alerta (PO)

#Módulos Core: El Ecosistema

La aplicación está estructurada en módulos seguros basados en roles, accesibles desde el dashboard principal. Cada módulo resuelve una fricción operativa específica:

📦
Bodega
Recepciones

Pre-alertas, tickets de recepción con fotos y documentos, consolidación automática de POs.

✈️
Despacho
Instrucciones de Salida

Manifiestos de exportación, AWB/BL, Loading Guide, seguimiento de ETA vs. entrega real.

⚖️
Finanzas
Cotizaciones

Motor automático: peso real vs. volumétrico, 11 conceptos de cargo, generación de PDF.

💼
Proveedores
Facturas

Facturas de bodega vinculadas a salidas específicas. Trazabilidad de costo real por operación.

📊
Datos
Reportes

Exportación XLSX, CSV y PDF. Dashboard de KPIs operativos en tiempo real.

🔐
Accesos
Multi-tenant

Roles diferenciados: STAFF_3PL, CLIENTE_ADMIN, CLIENTE_VISOR. Aislamiento total por empresa.

#Las decisiones técnicas que importan

1. La base de datos primero — y bien hecha

El mayor reto técnico no fue el frontend. Fue diseñar el modelo de datos correcto desde el inicio. Un paquete puede llegar en múltiples recepciones parciales. Varios paquetes de distintos clientes pueden salir consolidados en un mismo embarque. Un embarque puede desconsolidarse para enviar partes a distintos destinos. Las facturas de proveedores pueden cubrir una o múltiples salidas. Modelar eso mal al principio significaba reescribir todo después. El resultado: relaciones correctas, 13 índices estratégicos para las queries más frecuentes, y constraints que hacen imposible registrar datos inconsistentes.

2. Automatización que elimina el error humano

El error más costoso del sistema anterior era la intervención manual en cada cambio de estado. En VertS eso no existe. Cinco triggers en PostgreSQL se encargan de la consistencia sin que nadie lo active: cuando llega un ticket, el trigger suma los ítems y actualiza el PO; cuando el PO está completo, todos sus tickets cambian a LISTO_PARA_ENVIAR en una operación atómica; cuando se desconsolida, el trigger valida que los ítems hijos no excedan el padre. Cada mutación queda registrada en audit_logs con el estado antes y después.

3. Seguridad multi-tenant que vive en la base de datos

En una plataforma donde múltiples clientes ven su propia carga, la pregunta de seguridad no es '¿qué muestra el frontend?' sino '¿qué puede devolver la base de datos?'. Row Level Security en todas las tablas: un cliente autenticado físicamente no puede obtener datos de otro aunque manipule la URL, el token o la petición HTTP.

Perfiles & Roles

Auth Users → Tabla perfiles

Integración sobre la tabla auth nativa. Tres roles: STAFF_3PL, CLIENTE_ADMIN, CLIENTE_VISOR.

Seguro

Row Level Security

Policy: auth.uid() = user_id

Todas las consultas PostgreSQL filtran filas nativamente. El frontend no filtra — la BD lo hace.

Inquebrantable

Storage Buckets

Políticas sobre archivos

Fotos de recepción, invoices y PDFs de cotización protegidos en buckets con RLS.

Privado

Al aprovechar las herramientas nativas de PostgreSQL, la aplicación evita vulnerabilidades comunes en la capa backend tradicional y escala sin reescribir lógica de autorización cada vez que se agrega un módulo.

Server Action: aislamiento multi-tenant

El empresa_id nunca viene del cliente — se infiere del perfil autenticado. Así es imposible que un tenant inserte datos en el contexto de otro, aunque manipule la petición.

src/app/actions/reception.ts
"text-slate-500">// src/app/actions/reception.ts
"text-purple-400">import { createServerClient } "text-purple-400">from '@supabase/ssr'
"text-purple-400">import { cookies } "text-purple-400">from 'next/headers'

"text-purple-400">export "text-purple-400">async "text-purple-400">function createReception(data: ReceptionData) {
  "text-purple-400">const cookieStore = cookies()
  "text-purple-400">const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { get(name: string) { "text-purple-400">return cookieStore.get(name)?.value } } }
  )

  "text-slate-500">// 1. Verificar autenticación
  "text-purple-400">const { data: { user }, error: authError } = "text-purple-400">await supabase.auth.getUser()
  "text-purple-400">if (authError || !user) "text-purple-400">throw "text-purple-400">new Error('Unauthorized')

  "text-slate-500">// 2. Obtener empresa del perfil (nunca del cliente)
  "text-purple-400">const { data: profile } = "text-purple-400">await supabase
    ."text-purple-400">from('perfiles')
    .select('empresa_id')
    .eq('id', user.id)
    .single()

  "text-slate-500">// 3. Insertar con aislamiento multi-tenant garantizado por RLS
  "text-purple-400">const { data: newReception, error } = "text-purple-400">await supabase
    ."text-purple-400">from('tickets_recepcion')
    .insert([{
      ...data,
      estatus: 'RECIBIDO',
      creado_por: user.id,
      empresa_id: profile.empresa_id "text-slate-500">// Inferido del perfil, nunca del cliente
    }])
    .select()
    .single()

  "text-purple-400">if (error) "text-purple-400">throw "text-purple-400">new Error('Failed to create reception')
  "text-purple-400">return { success: true, data: newReception }
}

Triggers PostgreSQL: consistencia automática

Estos dos triggers son el corazón de la automatización. update_cantidad_recibidos mantiene el conteo del PO sincronizado en tiempo real. auto_sync_po_status dispara la consolidación atómica cuando el PO está completo — sin intervención humana.

supabase/triggers.sql
-- Trigger 1: Recalcula ítems recibidos en el PO
-- Se ejecuta en INSERT, UPDATE y DELETE sobre tickets_recepcion
CREATE OR REPLACE FUNCTION update_cantidad_recibidos()
RETURNS TRIGGER AS $$
BEGIN
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
    UPDATE paquetes_cliente
    SET cantidad_items_recibidos = (
      SELECT COALESCE(SUM(items_parciales_recibidos), 0)
      FROM tickets_recepcion
      WHERE paquete_cliente_id = NEW.paquete_cliente_id
        AND estatus NOT IN ('CANCELADO')
    )
    WHERE id = NEW.paquete_cliente_id;
    RETURN NEW;
  END IF;

  IF (TG_OP = 'DELETE') THEN
    UPDATE paquetes_cliente
    SET cantidad_items_recibidos = (
      SELECT COALESCE(SUM(items_parciales_recibidos), 0)
      FROM tickets_recepcion
      WHERE paquete_cliente_id = OLD.paquete_cliente_id
        AND estatus NOT IN ('CANCELADO')
    )
    WHERE id = OLD.paquete_cliente_id;
    RETURN OLD;
  END IF;
END;
$$ LANGUAGE plpgsql;

-- Trigger 2: Sincronización automática del estado del PO
-- Cuando el PO está completo, marca todos sus tickets como LISTO_PARA_ENVIAR
CREATE OR REPLACE FUNCTION auto_sync_po_status()
RETURNS TRIGGER AS $$
BEGIN
  IF NEW.ref_cliente_po IS NOT NULL THEN
    PERFORM sync_po_status(NEW.ref_cliente_po, NEW.cliente_id);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

#El impacto en números

Antes vs. después: el mismo equipo, el mismo volumen de operación, resultados completamente distintos.

Antes
Después
2–3 horas por instrucción de salida
~20 minutos
Información fragmentada en Excel, correo y WhatsApp
Un solo sistema, tiempo real
Estados de carga actualizados manualmente
Automatización por triggers de BD
Fotos y documentos en carpetas locales
Centralizados en Storage con acceso inmediato
Cálculo manual de cotizaciones (errores frecuentes)
Motor automático con 11 conceptos de cargo
Sin trazabilidad de quién hizo qué
Audit log automático en cada mutación
400+
Paquetes procesados
120+
Instrucciones de salida
~85%
Reducción en tiempo de despacho
0
Errores de aislamiento entre tenants

#Stack Tecnológico

Capa
Tecnología
Por qué
Framework
Next.js 15 (App Router)
SSR para carga inicial rápida. Server Actions para mutaciones sin API layer adicional.
UI
React 19 + Shadcn/UI + Radix
Componentes accesibles, sin estilos opinados. Iteración rápida.
Base de datos
Supabase (PostgreSQL 15+)
Auth + DB + Storage + RLS en una sola plataforma. Triggers nativos.
Validación
Zod
Schemas centralizados. Validación en servidor antes de tocar la BD.
PDFs
jsPDF + autotable
Generación en cliente para cotizaciones y manifiestos de exportación.
Deploy
Vercel
CI/CD continuo, zero-config al pushear al repo.

#Lo que sigue

🌐

Portal de clientes

Acceso directo para que los clientes carguen pre-alertas y vean el estado de su carga en tiempo real, sin necesidad de correos.

📈

Dashboard de análisis de costos

Comparativa cotización vs. factura real, alertas de vencimiento y métricas financieras por período.

📁

Documentos y costos por salida

Vista unificada de toda la documentación y los costos de proveedor vinculados a cada despacho.

#Reflexión

Este proyecto resolvió un problema real que los integrantes de la empresa vivían todos los días. Antes de escribir una sola línea de código, estudié la operación: levanté el estado actual bajo la metodología AS-IS / TO-BE, modelé los procesos en BPMN, e identifiqué los puntos de fricción exactos que el sistema debía eliminar. Solo entonces empecé a construir.

Aprendí más diseñando el modelo de datos de VertS que en cualquier tutorial. Y confirmé lo que la ingeniería industrial ya enseña: entender el proceso es el trabajo — la herramienta viene después.

— Jaime Arias · Diseño, arquitectura e implementación completa · Nov 2025 → Producción Feb 2026 → Evolución continua

¿Interesado en la arquitectura técnica o en una solución similar?

Contáctame