El prospecto de GlobalLink, dentro del sistema de TNI
Qué hay que hacer, quién lo hace y en qué orden; el cruce campo por campo; y cómo lo vive el vendedor. Lo demás, en anexos.
Todo lo que dice esta página sobre el sistema de TNI se leyó hoy de su base de datos real: catálogos, columnas, tipos, largos, obligatoriedad, y cómo TNI mismo inscribe sus prospectos en la oficina de Guatemala.
Resumen ejecutivo
El prospecto se inscribe en la ficha de clientes de TNI en estado 5, que es como Guatemala ya inscribe 1.268 prospectos. Cabe entero en el permiso de escritura ya redactado.
La firma del addendum, un usuario de escritura y el acceso de red. Sólo eso nos detiene. Las decisiones de datos y la equivalencia de cargos ya están resueltas de nuestro lado: se les informan, no se les preguntan. Quedan además dos objetos de base de datos que sólo ellos pueden crear —la columna anti-duplicados y la tabla del acceso—, pero ninguno de los dos nos detiene: el primero es opcional y el segundo va con el acceso, que no tiene fecha.
Diez tareas en cuatro bloques más una vía paralela. Nueve de las diez no dependen de TNI y arrancan hoy; la décima es el encendido, que espera la firma y los dos accesos. La escritura real es lo último y se enciende de a un paso.
El código documentaba el estado 4 como Potencial, y es «cancelado por el cliente»; el 5 es el bueno. El mapeo omitía columnas obligatorias y escribía dos que no existen. El modo rápido del espejo está apagado desde agosto.
Todo lo que se dice de TNI se leyó hoy de su base real. Seis pasadas de un revisor independiente; las dos últimas, sobre esta versión.
105 avisos esperan en la cola desde agosto y nunca se escribió una fila en TNI. Es a propósito: no hay escritura sin firma.
El prospecto se crea en la ficha de clientes de TNI en estado 5, Potencial; cada avance es un renglón «Lead» en su bitácora; al ganar, nace el servicio en etapa 7. Lo que TNI cambie vuelve cada noche por el espejo.
- Cabe entero en el permiso de escritura ya acordado: altas en Clientes, Contactos, Servicios y Propuestas. Nunca una modificación.
- Es como TNI ya trabaja: Guatemala tiene 1.268 clientes en estado 5 y registra sus gestiones de venta como renglones «Lead».
- El acceso desde su sistema se apoya en el número de empleado de su propia base, y eso ata solo cada vendedor.
- Hoy no sale nada hacia TNI: 105 avisos esperan en la cola desde agosto y nunca se escribió una fila. Encender es lo último, y se hace de a un paso.
El plan: quién responde por qué, qué depende de qué, y cinco días
Las seis preguntas que tiene que contestar este documento, en orden: de quién es cada cosa, qué falta, qué sigue para cada uno, cuál es el plan, qué bloquea a qué, y cómo se hace en una semana.
1.1 · De quién es cada cosa
Catalizadora responde por el mecanismo
- ConstruirEl aviso que sale cuando nace o avanza un prospecto, el mapeo a las columnas de TNI, la comprobación de duplicados, el paso de vuelta y el punto de entrada del acceso. Diez tareas.
- OperarEl programa que escribe corre en nuestro servidor, con topes de velocidad, reintentos, auditoría de cada intento y alerta si la cola se atasca.
- Responder por los datosQue lo que se escriba sea correcto, que no se duplique, que no se pierda, y que lo que TNI cambie se refleje. Si algo falla, se apaga desde nuestro lado en menos de 30 minutos.
- InformarUn reporte mensual con altas por tabla, tasa de éxito, backlog e incidentes, como fija el addendum.
- No responde porNada que ocurra dentro del sistema de TNI: su flujo de facturación, sus estados, sus usuarios. No modificamos ni borramos nada suyo, nunca.
TNI responde por tres cosas, y sólo tres
- AutorizarLa firma del addendum es el interruptor legal y técnico: sin ella no se escribe una sola fila.
- Dar accesoUn usuario de escritura acotado a cuatro tablas y el acceso de red a ese usuario. Son las dos únicas cosas técnicas que nos detienen, porque nadie fuera de TNI puede crearlas. Hay otros dos objetos que también sólo ellos pueden crear —la columna anti-duplicados de R4 y la tabla del acceso de R7—, pero ninguno bloquea: el primero es opcional y el segundo va sin fecha.
- No tiene que decidir nadaLas convenciones de inscripción y la equivalencia de cargos ya están decididas y medidas contra su propia base. Se les informan en la sección 2 con el porqué de cada una. Si alguna no les cuadra, se cambia con un correo; mientras tanto avanzamos.
- Su sistema, cuando puedanLa pestaña y la página del acceso con un clic: una tabla y dos a tres horas, con el código ya escrito en R7. No corre prisa y no bloquea nada: mientras no exista, la gente entra a GlobalLink con su usuario y contraseña, como hoy.
- No responde porNingún desarrollo del lado de GlobalLink, ninguna carga de datos, ninguna pantalla nueva. Sus prospectos aparecen en las pantallas que ya usa.
1.2 · Qué falta y qué sigue, para cada uno
| Quién | Lo próximo que tiene que hacer | Cuánto le toma | Qué desbloquea |
|---|---|---|---|
| Truly Nolen International · tres cosas nos bloquean. Nada que investigar, nada que decidir. | |||
| TNI Quien conozca el modelo de datos | Nada pendiente. Las convenciones de inscripción y la equivalencia de cargos ya están decididas y documentadas en la sección 2; se les informan para que las conozcan, no para que las aprueben. | Leerlas: 15 min | Nada. Si quieren cambiar alguna, un correo y se cambia. |
| TNI Dirección | Firmar el addendum COT-0003-A1 v2, ya redactado | Una firma | Habilita toda la escritura real |
| TNI Administrador de base de datos | Crear el usuario de escritura y abrir la red (R2, R3), con las sentencias ya escritas en la sección 2. La columna anti-duplicados (R4) es opcional y puede esperar. | 30 minutos | Permite la primera escritura. Junto con la firma, es lo único que nos bloquea. |
| TNI Equipo del sistema de gestión | Cuando puedan y sin prisa: la pestaña y la página del acceso (R6, R7), con el código ya escrito. No hay fecha comprometida para esto. | 2 a 3 horas | Sólo la comodidad de entrar con un clic. Mientras no exista, la gente entra con su usuario y contraseña de GlobalLink. |
| Catalizadora · empieza hoy, sin esperar nada | |||
| Catalizadora | Corregir el mapeo contra el esquema real y el estado 5 (C1, C10) | Día 1 | Sin esto, la primera escritura falla |
| Catalizadora | Los avisos que salen cuando el prospecto nace y avanza (C2, C3) | Día 2 | Empieza a acumularse lo que se va a escribir |
| Catalizadora | Duplicados, números devueltos y bitácora completa (C4, C5, C6) | Día 3 | Permite la prueba en sombra |
| Catalizadora | El paso de vuelta (C7) | Día 4 | Cierra el circuito de los dos sentidos |
| Catalizadora | Encender y verificar (C8), y el acceso (C9) | Día 5 | Prospectos reales en el sistema de TNI |
1.3 · Qué depende de qué
1.4 · El plan de cinco días
Cinco días hábiles, contados desde hoy. No esperan ninguna respuesta de TNI: lo único que puede correr la fecha del quinto día es que la firma y los dos accesos no hayan llegado. El objetivo del quinto día es que un prospecto creado por un vendedor de Guatemala aparezca en el sistema de TNI, con su vendedor y su bitácora, y que lo que TNI cambie vuelva esa noche.
| Día | Catalizadora | TNI | Al terminar el día |
|---|---|---|---|
| 1 lunes | Mapeo contra el esquema real: las cuatro columnas obligatorias que faltan, las dos que no existen fuera, los largos y la fecha correcta. Corrección del estado 5 en la vista y en el código C1 · C10 | Recibe este documento e inicia la firma del addendum. Pide media hora a su administrador de base de datos para R2 y R3 R1 · R2 · R3 | El mapeo escribe columnas que existen. Nada sale todavía hacia TNI. |
| 2 martes | Los avisos: cuando el prospecto nace con dueño, cuando se contacta, cuando agenda, cuando se propone, cuando se gana o se pierde. Regla de datos mínimos C2 · C3 | Crea el usuario de escritura y abre el acceso de red R2 · R3 | Cada prospecto nuevo deja su aviso en la cola, retenido. |
| 3 miércoles | Comprobación de duplicados contra la cartera de la oficina, guardado de los números que devuelve TNI, y la bitácora completa al espejo C4 · C5 · C6 | Si quieren y pueden, sin compromiso de fecha: la columna opcional y empezar la pestaña y la página de acceso R4 · R6 · R7 | Primera corrida en sombra: el programa dice, fila por fila, qué escribiría en TNI. Se lo compartimos. |
| 4 jueves | El paso de vuelta: lo que TNI cambie en la ficha, la bitácora o los servicios se aplica sobre el negocio. Revisión de la sombra contra veinte negocios reales C7 | Nada pendiente. Si quieren, prueban la página de acceso contra nuestra dirección de pruebas R7 | Informe de sombra: las veinte filas exactas que se escribirían. Se lo compartimos para que lo vean; no esperamos aprobación para seguir. |
| 5 viernes | Encendido gradual con el documento firmado en el servidor: cinco prospectos reales de Guatemala, verificación en la pantalla de TNI, y apagado inmediato si algo no cuadra C8 | Si quieren, miran en su pantalla los cinco prospectos. La cola se abre igual: la apertura no queda condicionada a una confirmación suya. | Prospectos reales en el sistema de TNI, con vendedor y bitácora. La cola se abre al resto. |
Si la firma y los accesos llegan esta semana
- El viernes hay prospectos reales en su sistema, verificados en su pantalla.
- El acceso con un clic queda probado de nuestro lado y esperando su página; mientras tanto la gente entra con usuario y contraseña.
- El lunes siguiente se abre la cola completa y empieza el reporte semanal.
Si se demoran
- Los cinco días entregan igual el motor completo, construido y probado en sombra: se ve exactamente qué se escribiría, sin escribir. No se pospone ninguna tarea nuestra.
- El encendido pasa a ser un trámite de dos horas el día que lleguen la firma y los accesos.
- Lo único que no puede adelantarse es la verificación en su pantalla, que necesita filas reales.
Sólo hay un bloqueo, y es la firma con los dos accesos: sin ellos no hay escritura real, por decisión contractual y no técnica. Ninguna otra cosa de TNI mueve la fecha, porque ninguna otra está esperando una respuesta suya. Nada más del plan depende de terceros.
1.5 · Las dieciocho tareas, en orden
El detalle de cada una: las de TNI en la sección 2, las de Catalizadora en la 3.
| Nº | Tarea | Dónde, exactamente | Quién | Cuándo |
|---|---|---|---|---|
| Bloque 1 · no depende de TNI · los negocios empiezan a encolarse sin salir hacia TNI | ||||
| C1 | Rehacer el mapeo de alta contra el esquema real: sumar las cuatro columnas obligatorias que faltan, quitar las dos que no existen, recortar a los largos reales, poner la fecha de creación | writeback_mapping.py · mapping.ts · docs/32 · prueba en eval-mapeo-datos.py | Catalizadora | Bloque 1 |
| C2 | Los cinco avisos nuevos en la cola: alta con dueño, contactado, propuesta enviada, perdido, reactivado. Un índice único por evento y el candado en la función de reclamo | migración nueva, molde de la 302 · app.dequeue_writeback_pending | Catalizadora | Bloque 1 |
| C10 | Corregir el estado 4 tomado como Potencial: es el 5 | gold.customer_mirror_opportunities · customer-mirror.ts · icp.jsonl · docs/57 | Catalizadora | Bloque 1 |
| Bloque 2 · verificable en sombra · no depende de TNI | ||||
| C3 | Datos mínimos: si falta teléfono, dirección o localidad el aviso queda retenido con motivo, y se libera cuando el dato llega | app.writeback_outbox · estado held + held_reason | Catalizadora | Bloque 2 |
| C4 | Duplicados antes de escribir: teléfono exacto y nombre normalizado contra la cartera de la oficina; si existe, se enlaza y no se crea | bin/writeback.py · criterio de ya-es-cliente.ts | Catalizadora | Bloque 2 |
| C5 | Guardar los números que devuelve TNI: cliente, contacto, servicio, propuesta | app.writeback_outbox +3 columnas · app.leads.payload · id_cliente_creado a la noche | Catalizadora | Bloque 2 |
| C6 | La bitácora de TNI viaja completa al espejo: siete columnas más | populate 02 · silver.contactos +3 columnas | Catalizadora | Bloque 2 |
| T4 | La columna anti-duplicados con índice único, en cuatro tablas y tres bases. Opcional: la protección ya corre de nuestro lado | idempotency_key · requerimiento R4 | TNI | Bloque 2 |
| T5 | Las convenciones de inscripción: estado 5 y tipo 1, «C/F» en Identidad cuando no se conoce, y «Otro» con el texto «TN GlobalLink» como referencia. Decidido: se informa a TNI, no se le pregunta | AuxStatusClientes · AuxTiposClientes · AuxTiposReferencias | Catalizadora | Bloque 1 |
| Bloque 3 · no depende de TNI | ||||
| C7 | El paso de vuelta: cada noche, lo que TNI cambió en la ficha, la bitácora y los servicios se aplica sobre el negocio | app.reconciliar_prospectos_con_tni() · después de tn-espejo-base@ | Catalizadora | Bloque 3 |
| Bloque 4 · primera escritura real · se enciende de a un paso | ||||
| T1 | Firmar la autorización; nosotros la depositamos en el servidor | /etc/tn-globallink/writeback-addendum-firmado | TNI | Bloque 4 |
| T2 | Usuario de escritura: sólo altas, cuatro tablas, tres bases. Recibido el 4-sep; falta prueba negativa | user_globallink_writer · requerimiento R2 | TNI | Bloque 4 |
| T3 | Acceso de red a ese usuario desde nuestra única dirección | puerto del SQL Server · IP por canal privado | TNI | Bloque 4 |
| C8 | Operar el programa de escritura: cada 15 minutos, topes, reintentos, auditoría, alerta si se atasca | tn-writeback.timer · TN_WRITEBACK_APLICAR=1 · tn-alert-email.sh | Catalizadora | Bloque 4 |
| En paralelo · el acceso desde su sistema | ||||
| C9 | El punto de entrada que recibe la credencial, hace las once comprobaciones y abre sesión; alta automática por número de empleado | app/auth/exchange/route.ts · proxy.ts · public.users.id_empleado_tni · 2 tablas nuevas | Catalizadora | Paralelo |
| T6 | La pestaña «Truly Nolen GlobalLink» en su sistema, con el logo y el fragmento que entregamos | plantilla del menú del sistema de gestión | TNI | Paralelo |
| T7 | La página que emite el JWT HS256 y lo envía por POST; el secreto por Bitwarden Send. En curso del lado de TNI (4-sep). | una página en su sitio · dbo.Empleados | TNI | Paralelo |
| T8 | Qué cargo de su tabla de empleados equivale a cada rol de GlobalLink. Decidido sobre sus 43 cargos; TNI puede objetar una fila | app.tni_cargo_rol, la llenamos nosotros | Catalizadora | Paralelo |
Especificación de implementación para el equipo de TNI
Ocho puntos, de los cuales sólo tres nos bloquean. Cada uno trae el objeto exacto, la sentencia lista para ejecutar cuando la hay, la consulta de verificación con el resultado que tiene que devolver, el criterio de aceptación y lo que no hay que hacer. Ningún valor, nombre ni ruta queda librado a interpretación.
2.0 · El entorno, para que no haya duda de dónde
| Elemento | Valor exacto | Cómo lo sabemos |
|---|---|---|
| Instancia | VMDBAPP | Devuelto por SELECT @@SERVERNAME el 3 de septiembre de 2026 |
| Motor | Microsoft SQL Server 2019 (RTM-GDR) 15.0.2180.2 | SELECT @@VERSION |
| Puerto | TCP 11433 | El que ya usa el usuario lector |
| Usuario lector actual | user_globallink | Con permiso de sólo lectura; no se modifica ni se reemplaza |
| Bases productivas donde se escribe | Truly · TrulyNew · TrulyRuso | Las tres que TNI confirmó como productivas el 31 de julio de 2026 |
| Base prohibida | TrulyGestion | Copia de pruebas. Nuestro programa la rechaza por nombre antes de conectarse |
| Tablas destino | dbo.Clientes · dbo.Contactos · dbo.Servicios · dbo.Propuestas | Las cuatro autorizadas por el addendum; ninguna otra |
| Columnas de identidad | IdCliente · IdContacto · IdServicio · IdPropuesta | Son IDENTITY; no se insertan, se recuperan con OUTPUT INSERTED |
| Guatemala | IdOficina 679 en la base Truly · IdPais 244 · CodigoPais «GTB» | Medido: 14.941 clientes y alta de ayer. Ojo: en TrulyNew el número 679 es otra oficina, de otro país |
| Dirección desde la que escribimos | una sola IP, /32 se entrega por canal privado, no por este documento | Es la misma desde la que hoy se conecta el usuario lector |
2.1 · Lo que ya hicimos, para no pedírselo a ustedes
Antes de escribir esta lista de requerimientos fuimos a su base y averiguamos todo lo que se podía averiguar sin molestarlos. Esta sección existe para que quede claro que no les estamos trasladando el trabajo de investigación: lo que queda en la sección 2 es lo que, por permisos o por autoridad, sólo puede salir de ustedes.
| Lo que había que averiguar | Lo que hicimos nosotros, y cuándo | Lo que les habría costado a ustedes |
|---|---|---|
| Con qué estado se inscribe un prospecto | Leímos su catálogo AuxStatusClientes el 3 de septiembre y contamos los clientes de la oficina 679 por estado. Resultado: el prospecto es el estado 5, «Potential». Nuestro propio código decía 4, que en su catálogo es «Cancelado por el cliente». Al ir a corregirlo verificamos que estaba en los comentarios y no en la condición —la lógica agrupa como oportunidad todo lo que no sea «Activo», así que nunca dio un resultado equivocado—, pero el alta sí lo habría escrito mal. Corregido antes de escribirles. | Una consulta y una revisión de código que no era suya |
| Cómo llenan los campos en la práctica | Medimos las convenciones reales sobre 1.268 prospectos de Guatemala, 1.364 gestiones de venta de los últimos 365 días y 919 servicios de los últimos 180. Sabemos qué llenan siempre, qué dejan vacío y con qué valores. | Varias horas de alguien que conozca el sistema |
| Qué columnas son obligatorias | Leímos el catálogo de columnas de sus tres bases productivas: nombre, tipo, largo y obligatoriedad de cada una. Encontramos que nuestro mapeo omitía cuatro columnas obligatorias de Clientes —más dos de Contactos y ocho de Servicios— y escribía dos nombres de columna que no existen. Lo arreglamos nosotros; ninguna de esas fallas llegó a su base. | Un rechazo en la primera escritura y una ida y vuelta de diagnóstico |
| Qué cargo corresponde a qué permiso | Leímos su catálogo de 43 cargos con la cantidad de empleados activos de cada uno, y les entregamos la equivalencia ya llena (R8) para los 10 que tienen gente: 2.018 personas con rol y 2.738 deliberadamente fuera: 2.667 técnicos de campo y 71 «Otro». | Diseñar un modelo de permisos desde cero |
| Cómo se identifica cada país | Comprobamos que su código de país no es el estándar internacional en 13 de sus 239 países —Guatemala es «GTB»—. Por eso el acceso no les pide que manden el país: lo leemos de su base y lo traducimos nosotros. | Una fuente de errores silenciosos, difíciles de ver |
| Si el acceso necesita permisos nuevos | Consultamos los permisos efectivos del usuario de lectura que ya nos dieron y confirmamos que su alcance es a nivel de base: la tabla nueva del acceso queda cubierta sin darnos ningún permiso adicional. Por eso R7 no pide un GRANT para nosotros. | Una solicitud de permisos y su revisión de seguridad |
| Cómo se escribe la página del acceso | La escribimos nosotros, completa, en las dos tecnologías que conviven en su portal: ASP clásico y ASP.NET. Está en R7, lista para copiar, con la consulta de verificación de cada paso. | Diseñar el mecanismo y escribirlo desde cero |
2.2 · Los ocho puntos, agrupados por lo que de verdad nos detiene
No son ocho pedidos equivalentes. Tres nos bloquean y cinco no. La diferencia importa: nadie va a estar esperando una respuesta suya sobre las cinco de abajo, porque ya están decididas y en construcción.
| # | Qué es | Quién lo ejecuta | Esfuerzo | Qué pasa si no llega |
|---|---|---|---|---|
| Nos bloquea · sin estas tres no se escribe una sola fila, y nadie fuera de TNI puede darlas | ||||
| R1 | Addendum firmado | Dirección | una firma | Se detiene toda la escritura. Es una barrera contractual y además un candado en el programa: sin el PDF en el servidor, no emite ninguna alta. |
| R2 | Usuario de escritura en las tres bases | Administrador de base de datos | 20 min | No hay con qué conectarse a insertar. El usuario de lectura que ya tenemos no sirve, a propósito. |
| R3 | Acceso de red para ese usuario | Redes o el mismo DBA | 10 min | No se llega al servidor. Se comprueba en un minuto con la consulta de R3. |
| No nos detiene · dos son decisiones ya tomadas que se les informan, y una es una mejora opcional en su base | ||||
| R4 | Columna idempotency_key en cuatro tablas por base | Administrador de base de datos | 15 min | Nada. Sin ella, la protección contra duplicados la hacemos con una marca de texto, que ya está construida y funciona. La columna es más limpia para ustedes, no para nosotros. |
| R5 | Las convenciones con que se inscribe el prospecto | — | leerlas: 15 min | Nada. Escribimos con lo decidido y queda registrado en cada alta. Si prefieren otro valor, un correo y se cambia en un minuto. |
| R8 | Equivalencia de cargos con el rol de GlobalLink | — | leerla: 15 min | Nada. La aplicamos como está propuesta. Un cargo que no esté en la tabla no entra, que es el comportamiento seguro. |
| En su sistema, cuando puedan · sin fecha comprometida | ||||
| R6 | Pestaña en el menú del sistema de gestión | Equipo del portal | 1 h | Nada se detiene. La gente entra a GlobalLink con su usuario y contraseña, como hoy. |
| R7 | Página que identifica al usuario, con el código ya escrito | Equipo del portal | 2 a 3 h | Nada se detiene. Es la comodidad de entrar con un clic; nuestro lado queda construido y probado esperándola. |
R1 · Addendum de escritura firmado
Objetivo. Autorizar por escrito las altas descritas en el addendum COT-0003-A1 versión 2, que ya está redactado y no requiere cambios de nuestro lado.
Qué se firma. El documento «Addendum COT-0003-A1 · Autorización de write-back limitado, versión 2 del 20 de agosto de 2026». Firman José Lutz por Truly Nolen International y Pablo Estrada por AI Catalysts. Autoriza únicamente altas en cuatro tablas de tres bases, prohíbe modificar y borrar, y puede suspenderse por correo con efecto inmediato.
Qué hacemos nosotros con él. Depositamos el PDF firmado en el servidor de la plataforma, en la ruta /etc/tn-globallink/writeback-addendum-firmado. El programa de escritura verifica que ese archivo exista y no esté vacío antes de cada corrida; si no está, no emite ninguna alta y lo deja escrito en su registro.
Criterio de aceptación. PDF firmado por ambas partes, en cualquier formato de firma. No hace falta plataforma de firma electrónica.
Una salvedad, para que quede escrita. El texto del addendum describe idempotency_key como columna obligatoria. En la práctica se aplica como nula con índice único filtrado (R4) —de otro modo fallaría sobre las filas que ya existen— y, además, R4 pasó a ser opcional. No cambia el alcance autorizado ni las tablas afectadas; si prefieren corregir esa línea antes de firmar, lo hacemos en el día.
Qué no hace falta. Ningún anexo técnico nuevo: el memo que lo acompaña ya está entregado.
R2 · Usuario de escritura user_globallink_writer
Objetivo. Un login nuevo, distinto del lector, que sólo pueda insertar en cuatro tablas de las tres bases productivas.
| Tabla | Cuándo se escribe | Qué es la fila | Cuántas filas |
|---|---|---|---|
| dbo.Clientes | Al nacer el prospecto | El prospecto mismo, en estado 5 «Potencial». Es la ficha del negocio | Una por negocio, una sola vez |
| dbo.Contactos | Al nacer, y en cada hito | Un renglón de bitácora. Ojo: en su sistema Contactos es el registro de visitas y llamadas, no un directorio de personas | Una por hito del negocio |
| dbo.Servicios | Sólo si se gana | El servicio vendido, en la etapa 7, la misma en la que su gente crea los suyos | Una por negocio ganado |
| dbo.Propuestas | Sólo si se gana | La propuesta. No cuelga del cliente sino del servicio: se llega por IdServicio | Una por negocio ganado |
Por qué uno nuevo y no ampliar el actual. Para poder revocar la escritura sin tocar la lectura, y para que cualquier auditoría vea en el catálogo de permisos exactamente qué puede hacer cada uno.
Objetos a crear. Un login de servidor y tres usuarios de base, uno por base:
| Objeto | Nombre exacto | Dónde | Permisos |
|---|---|---|---|
| LOGIN | user_globallink_writer | instancia VMDBAPP | ninguno a nivel servidor |
| USER | user_globallink_writer | Truly | INSERT y SELECT en cuatro tablas; DENY UPDATE y DELETE en las mismas |
| USER | user_globallink_writer | TrulyNew | igual |
| USER | user_globallink_writer | TrulyRuso | igual |
Por qué también SELECT. Porque el alta devuelve el número asignado con OUTPUT INSERTED.IdCliente, y sin permiso de lectura sobre esa tabla el motor rechaza la cláusula. El SELECT se limita a esas cuatro tablas; el resto de la base queda fuera.
Script, listo para ejecutar. La contraseña la generan ustedes y nos la entregan por la bóveda de secretos, nunca por correo ni por chat:
/* ---------- 1) una sola vez, en la instancia ---------- */ USE [master]; GO CREATE LOGIN [user_globallink_writer] WITH PASSWORD = N'<la que ustedes generen>', CHECK_POLICY = ON, CHECK_EXPIRATION = OFF, DEFAULT_DATABASE = [Truly]; GO /* ---------- 2) repetir el bloque completo en Truly, TrulyNew y TrulyRuso ---------- */ USE [Truly]; -- luego [TrulyNew], luego [TrulyRuso] GO CREATE USER [user_globallink_writer] FOR LOGIN [user_globallink_writer]; GO GRANT INSERT, SELECT ON [dbo].[Clientes] TO [user_globallink_writer]; GRANT INSERT, SELECT ON [dbo].[Contactos] TO [user_globallink_writer]; GRANT INSERT, SELECT ON [dbo].[Servicios] TO [user_globallink_writer]; GRANT INSERT, SELECT ON [dbo].[Propuestas] TO [user_globallink_writer]; GO DENY UPDATE, DELETE ON [dbo].[Clientes] TO [user_globallink_writer]; DENY UPDATE, DELETE ON [dbo].[Contactos] TO [user_globallink_writer]; DENY UPDATE, DELETE ON [dbo].[Servicios] TO [user_globallink_writer]; DENY UPDATE, DELETE ON [dbo].[Propuestas] TO [user_globallink_writer]; GO
Verificación. Ejecutar en cada base conectado como ese usuario. Devuelve una fila por tabla y permiso, incluidas las denegaciones: tienen que salir dieciséis filas — ocho GRANT de INSERT y SELECT, y ocho DENY de UPDATE y DELETE. Las ocho de DENY son la prueba de que la restricción quedó puesta, no un defecto:
SELECT o.name AS tabla, p.permission_name, p.state_desc
FROM sys.database_permissions p
JOIN sys.objects o ON o.object_id = p.major_id
WHERE p.grantee_principal_id = DATABASE_PRINCIPAL_ID('user_globallink_writer')
ORDER BY o.name, p.permission_name;
-- y la prueba negativa: esto TIENE que fallar con error 229
UPDATE dbo.Clientes SET Observaciones = Observaciones WHERE 1 = 0;
Criterio de aceptación. Las dieciséis filas —ocho concedidas y ocho denegadas— y el error 229 en la prueba negativa. Nosotros repetimos la misma verificación desde nuestro servidor y la dejamos en el registro de la primera corrida.
Qué no hacer. No agregar el usuario a db_datawriter ni a ningún rol fijo: eso le daría escritura sobre toda la base. No darle EXECUTE sobre procedimientos. No crearlo en TrulyGestion. No reutilizar la contraseña del lector.
R3 · Acceso de red para el usuario de escritura
Objetivo. Que las conexiones de ese usuario, desde nuestra única dirección, lleguen al puerto del motor.
| Parámetro | Valor |
|---|---|
| Origen | una sola IP con máscara /32 · se entrega por canal privado · es la misma del lector actual |
| Destino | instancia VMDBAPP |
| Puerto y protocolo | TCP 11433 |
| Sentido | entrante; no hace falta ninguna regla de salida hacia nosotros |
Cómo se comprueba, y por qué hacen falta los dos lados. Esta regla es la única de las tres que no se puede verificar desde dentro de su red: una prueba de conexión lanzada desde su propio servidor devuelve «conectado» con la regla puesta o sin ella, porque no atraviesa el firewall. Se comprueba en dos pasos:
1 · Ustedes, que el motor escucha. Desde el servidor de base de datos, confirma que el puerto es el correcto y que la instancia responde. No prueba la regla:
Test-NetConnection -ComputerName localhost -Port 11433
# TcpTestSucceeded : True → el motor escucha en 114332 · Nosotros, que la regla deja entrar. Abrimos una conexión desde la IP autorizada con el usuario de R2, en modo sombra, sin insertar nada, y les confirmamos el resultado el mismo día. Es lo único que prueba de verdad que la lista blanca quedó bien.
Criterio de aceptación. Nuestra conexión entra desde la IP autorizada, y falla desde cualquier otra. Ese par —entra una, no entran las demás— es lo que demuestra que es una lista blanca y no una apertura general. Se lo pasamos por escrito el mismo día.
Qué no hacer. No abrir el puerto a rangos amplios ni a Internet. Si su política exige VPN en lugar de lista blanca, decirlo y coordinamos: el programa corre en un servidor fijo y puede usar un túnel.
R4 · Columna idempotency_key con índice único filtrado
Objetivo. Que si nuestro programa reintenta un alta que en realidad ya entró (corte de red justo después del INSERT), sea el propio motor de SQL Server el que rechace la segunda.
Corrección del 4 de septiembre: esto dejó de ser opcional. En la versión anterior de este documento lo presentamos como «una mejora para ustedes, que nosotros no necesitamos». Era una lectura incompleta y la corregimos acá. Es cierto que la protección contra duplicados funciona sin esta columna: antes de insertar comparamos contra la cartera de la oficina y marcamos cada alta con el identificador del negocio dentro del texto de Observaciones. Pero eso resuelve nuestro problema, no el suyo. Mientras el identificador viva dentro de un texto libre, el negocio no existe como dato en su base: no se puede consultar, no se puede indexar, y no se puede cruzar con nada. Y en dbo.Propuestas, que no tiene columna de observaciones, directamente no queda ningún rastro. Esta columna es la única forma de que un prospecto creado por GlobalLink tenga identidad propia del lado de TNI. Sigue sin bloquear la primera escritura —si no está, escribimos igual—, pero ya no la presentamos como prescindible. El detalle está en 5.1.
Objetos a crear. Doce columnas y doce índices: cuatro tablas por cada una de las tres bases.
| Tabla | Columna | Tipo | Índice |
|---|---|---|---|
| dbo.Clientes | idempotency_key | UNIQUEIDENTIFIER NULL | UX_Clientes_idempotency_key · único, filtrado |
| dbo.Contactos | idempotency_key | UNIQUEIDENTIFIER NULL | UX_Contactos_idempotency_key |
| dbo.Servicios | idempotency_key | UNIQUEIDENTIFIER NULL | UX_Servicios_idempotency_key |
| dbo.Propuestas | idempotency_key | UNIQUEIDENTIFIER NULL | UX_Propuestas_idempotency_key |
Por qué nula y con índice filtrado, y no obligatoria. El texto del addendum la describe como obligatoria. Aplicarla así fallaría: las filas que ya existen no tienen clave, y una columna obligatoria sin valor por omisión no se puede agregar a una tabla con datos. La forma de acá deja el histórico intacto, el índice sólo mira las filas que traen clave, y el efecto anti-duplicado es el mismo. Es una mejora sobre el texto firmado, no una desviación que haya que documentar: el addendum autoriza altas, y esta columna sólo las hace más seguras. Como además es opcional, no cambia nada de lo firmado.
Script, listo para ejecutar. Repetir el bloque completo en Truly, TrulyNew y TrulyRuso:
USE [Truly]; -- luego [TrulyNew], luego [TrulyRuso]
GO
ALTER TABLE [dbo].[Clientes] ADD [idempotency_key] UNIQUEIDENTIFIER NULL;
ALTER TABLE [dbo].[Contactos] ADD [idempotency_key] UNIQUEIDENTIFIER NULL;
ALTER TABLE [dbo].[Servicios] ADD [idempotency_key] UNIQUEIDENTIFIER NULL;
ALTER TABLE [dbo].[Propuestas] ADD [idempotency_key] UNIQUEIDENTIFIER NULL;
GO
CREATE UNIQUE NONCLUSTERED INDEX [UX_Clientes_idempotency_key]
ON [dbo].[Clientes] ([idempotency_key]) WHERE [idempotency_key] IS NOT NULL;
CREATE UNIQUE NONCLUSTERED INDEX [UX_Contactos_idempotency_key]
ON [dbo].[Contactos] ([idempotency_key]) WHERE [idempotency_key] IS NOT NULL;
CREATE UNIQUE NONCLUSTERED INDEX [UX_Servicios_idempotency_key]
ON [dbo].[Servicios] ([idempotency_key]) WHERE [idempotency_key] IS NOT NULL;
CREATE UNIQUE NONCLUSTERED INDEX [UX_Propuestas_idempotency_key]
ON [dbo].[Propuestas] ([idempotency_key]) WHERE [idempotency_key] IS NOT NULL;
GO
Impacto medido. ALTER TABLE ADD de una columna nula es un cambio de metadatos: no reescribe las filas. Sobre Clientes de Truly son 464.278 filas y la operación es inmediata. Los cuatro índices se crean vacíos, porque ninguna fila existente tiene clave. Su aplicación no necesita conocer la columna: no aparece en ningún SELECT * de pantalla porque es nueva y nula.
Verificación. En cada base, tiene que devolver cuatro filas con is_nullable = 1 y cuatro índices has_filter = 1:
SELECT t.name AS tabla, c.name AS columna, c.is_nullable, ty.name AS tipo FROM sys.columns c JOIN sys.tables t ON t.object_id = c.object_id JOIN sys.types ty ON ty.user_type_id = c.user_type_id WHERE c.name = 'idempotency_key' ORDER BY t.name; SELECT t.name AS tabla, i.name AS indice, i.is_unique, i.has_filter, i.filter_definition FROM sys.indexes i JOIN sys.tables t ON t.object_id = i.object_id WHERE i.name LIKE 'UX_%_idempotency_key' ORDER BY t.name;
Criterio de aceptación. Cuatro columnas y cuatro índices por base, doce y doce en total. Nosotros lo confirmamos porque la columna aparece en el catálogo que leemos cada noche.
Qué no hacer. No ponerla obligatoria. No crear el índice sin el filtro WHERE … IS NOT NULL: sin él, un solo valor nulo por tabla sería admitido y el resto rechazado. No agregarla a TrulyGestion.
R5 · Las convenciones con que vamos a escribir
Objetivo. Que el prospecto que escribimos se vea exactamente como los que inscribe su gente. Estas tres convenciones ya están decididas y en construcción: el 3 de septiembre leímos su catálogo y medimos cómo llenan los campos sobre los 1.268 prospectos de la oficina de Guatemala, y elegimos en consecuencia. Esta sección existe para que sepan exactamente qué vamos a escribir y por qué, con la consulta al lado para que puedan comprobarlo. No estamos esperando una respuesta. Si alguna de las tres no les cuadra, un correo y se cambia: es una constante del programa, cuesta un minuto.
| Lo que medimos en su base | En la oficina 679 hay 1.268 clientes con IdStatusCliente = 5, que su catálogo AuxStatusClientes llama «Potential». De ésos, 908 tienen IdTipoCliente = 2 «Office», 303 tienen 1 «Creative» y 57 tienen 3 «Other». |
| Lo que vamos a escribir | IdStatusCliente = 5 y IdTipoCliente = 1 «Creative». |
| Por qué | El estado no admite duda. El tipo sí: elegimos «Creative» porque el prospecto sale de prospección activa, no de una llamada entrante, aunque su práctica mayoritaria sea «Office». Es el único punto donde nos apartamos de lo que hace su gente hoy, y por eso lo señalamos. |
| Estado de la decisión | Tomada. Se escribe así desde la primera alta. Cambiarla después es una constante y una corrida de corrección. |
-- comprueben lo anterior en un minuto
USE [Truly];
SELECT s.IdStatusCliente, s.StatusCliente_01 AS ingles, s.StatusCliente_02 AS espanol,
COUNT(c.IdCliente) AS clientes_en_la_679
FROM dbo.AuxStatusClientes s
LEFT JOIN dbo.Clientes c ON c.IdStatusCliente = s.IdStatusCliente AND c.IdOficina = 679
GROUP BY s.IdStatusCliente, s.StatusCliente_01, s.StatusCliente_02
ORDER BY s.IdStatusCliente;
| Lo que medimos en su base | Los 1.268 prospectos de la 679 tienen Identidad con contenido: el 100 %. La columna es obligatoria y no admite nulos. |
| Nuestra situación | Sólo tenemos el número fiscal cuando el registro oficial del país lo publica. En Guatemala no siempre ocurre. |
| Lo que vamos a escribir | El texto fijo C/F cuando no lo tengamos, y el número real cuando sí. |
| Por qué | Es la abreviatura de «consumidor final», que ya se usa en la facturación de la región, y no inventa un número que después nadie sabe si es real. Descartamos dejarlo vacío porque la columna es obligatoria, y descartamos un provisional con formato propio porque ensucia su base. |
| Estado de la decisión | Tomada. Si prefieren otro texto fijo, se cambia con un correo. |
-- comprueben que la columna se llena siempre, y con qué
USE [Truly];
SELECT COUNT(*) AS prospectos,
SUM(CASE WHEN LTRIM(RTRIM(ISNULL(Identidad,''))) = '' THEN 1 ELSE 0 END) AS sin_identidad
FROM dbo.Clientes
WHERE IdOficina = 679 AND IdStatusCliente = 5;
| Lo que medimos en su base | Su catálogo AuxTiposReferencias tiene diez filas: TV, Web, Publicidad vía pública, Otro, Referencia de otro cliente, Auto de Truly Nolen, Radio, Páginas amarillas, Referencia de otro vendedor y Redes sociales. Ninguna sirve para distinguir el origen nuevo. |
| Lo que vamos a escribir | IdTipoReferencia = 6 «Otro», y en ReferenciaOtra el texto TN GlobalLink · Rocket o TN GlobalLink · Manual según de dónde venga. |
| Por qué | No les obliga a tocar su catálogo. La alternativa mejor para ustedes es agregar una fila «GlobalLink» a AuxTiposReferencias: el origen quedaría contable en sus reportes en vez de escondido en un texto libre. Si la agregan, denos el número y usamos ése; el cambio de nuestro lado es de un minuto. |
| Estado de la decisión | Tomada, con la opción «Otro». Si agregan la fila «GlobalLink» a su catálogo, avísennos el número y pasamos a usarlo: para ustedes es mejor, porque el origen queda contable en sus reportes. |
Cómo lo van a ver. Antes de escribir nada en firme les mostramos la primera alta de prueba con estos tres valores puestos, sobre un prospecto real de Guatemala. Ahí se ve el resultado, no la promesa.
Por qué no se lo preguntamos. Porque ya teníamos con qué decidirlo: su propia base. Devolverles una pregunta que nosotros podíamos contestar sería trasladarles trabajo y sumar una espera al proyecto. Lo único que de verdad no podemos hacer sin ustedes es escribir, y eso depende de R1, R2 y R3.
R6 · Pestaña «Truly Nolen GlobalLink» en el menú
Objetivo. Una pestaña más en la barra donde hoy están «Home» y «Reports», a la derecha de ellas.
Va junto con R7 y, como aquélla, no bloquea nada ni tiene fecha comprometida.
Dónde. En la plantilla que dibuja esa barra. En la instalación actual la barra se arma en /proposals/TG/includes/inc_menu_superior.asp, que es el archivo que aparece en el error cuando una página se abre fuera de su flujo. Si la plantilla real es otra, es la que corresponda: el requisito es que la pestaña salga en todas las pantallas del sistema.
Qué entregamos nosotros. El icono del ratón de Truly Nolen en SVG y en PNG de 16 y 32 píxeles (el de 32 es para pantallas de alta densidad), y este fragmento, adaptable a los estilos de su barra:
<a class="tab" href="/proposals/TG/globallink.asp" <!-- .aspx si la hacen en ASP.NET --> target="_blank" rel="noopener noreferrer"> <img src="/proposals/TG/img/tn-globallink.png" width="16" height="16" alt="" /> Truly Nolen GlobalLink ↗ </a>
Las dos rutas van absolutas, y esto importa. Este fragmento vive en un archivo que se incluye desde todas las pantallas del sistema. Una ruta relativa se resuelve contra la dirección de la pantalla que la pide, no contra la del archivo incluido: desde /proposals/TG/reports/… el enlace apuntaría a /proposals/TG/reports/globallink.asp, que no existe, y el icono no cargaría. Con la barra inicial funciona igual desde cualquier pantalla. Si su instalación cuelga de otra carpeta, es esa ruta con barra inicial.
Reglas. La pestaña apunta a la página de R7, dentro de su propio sitio, nunca directamente a GlobalLink. Se abre en una pestaña nueva, para que la persona no pierda lo que estaba haciendo. La flecha avisa que sale del sistema.
Quién la ve. Ésta sí es decisión suya, porque es su menú y su gente. Nuestra recomendación, si no quieren pensarlo: los mismos perfiles que hoy ven el módulo de ventas. Si prefieren empezar sólo con Guatemala, basta condicionar la pestaña a IdPais = 244 mientras dure el piloto.
Criterio de aceptación. Con el usuario de prueba que ya tenemos, la pestaña aparece y abre la página de R7.
R7 · La página que emite el token y lo envía a GlobalLink
Objetivo. Que un empleado que ya inició sesión en el sistema de gestión llegue a GlobalLink identificado, sin escribir otra contraseña. Junto con la pestaña de R6, es el único desarrollo del lado de TNI, y desde el 4 de septiembre es sólo una página: ya no hay tabla que crear ni permiso que dar.
El contrato del token · lo que su emisor produce
Cabecera. {"alg":"HS256","typ":"JWT"}. Cualquier otro algoritmo —incluido none— se rechaza antes de mirar el contenido.
Payload. Éste es el ejemplo que nos mandaron, y es exactamente lo que vamos a leer:
{
"Id": 12345, ← dbo.Empleados.IdEmpleado (confirmar)
"Nombre": "Roberto",
"Apellido": "Gonzalez",
"Email": "rgonzalez@trulynolen.com",
"Idioma": "01", ← el número de columna TextoNN · ver 8.9
"BD": "Truly", ← Truly · TrulyNew · TrulyRuso (su ejemplo decía TrulyGestion: ver abajo)
"iat": 1893456000,
"exp": 1893456120 ← 120 s después. Coincide con nuestros 2 minutos
}
| Campo | Viene | Qué hacemos con él, y qué falta confirmar |
|---|---|---|
| Id | sí | Lo buscamos en dbo.Empleados de la base que dice BD, con el usuario lector que ya tenemos. De ahí salen cargo, oficina, país y estado. Confirmar que es IdEmpleado. |
| Nombre · Apellido · Email | sí | Con ellos damos de alta a la persona la primera vez. El correo se compara con el de Empleados: si no coincide, se anota y manda el de su base. |
| Idioma | sí | Su ejemplo confirmó nuestra lectura: cadena con cero a la izquierda, número de columna de Interfase. "01" = inglés. La equivalencia completa está en 8.9. |
| BD | sí | Dice en qué base vive el empleado. ⚠️ Su ejemplo decía TrulyGestion, que para nosotros es la copia de pruebas: nuestro programa tiene prohibido escribir ahí. Confirmar que los valores reales son Truly, TrulyNew y TrulyRuso. |
| iat · exp | sí | Dos minutos de vida. Toleramos ±60 s de diferencia de reloj; más de 5 minutos de vida se rechaza aunque venga firmado. |
| jti | pedimos | Un identificador único por token (UUID). Es lo que hace limpio el «una sola vez». Sin él funcionamos igual —guardamos la huella del token— pero es la forma correcta. |
| iss · aud | pedimos | "iss":"tni-management" y "aud":"tn-globallink". Evita que un token emitido por otro sistema suyo, firmado con el mismo secreto, entre acá. |
| IdCargo · IdOficina | no hace falta | No los pidan a su página: los leemos de Empleados. Menos que programar, y el dato sale de la fuente. |
Cómo viaja · POST, no en la dirección
El token lleva nombre y correo en base64, no cifrados: cualquiera que lo vea lo lee. Si viajara en la dirección (?token=…) quedaría escrito en registros de servidores, en el historial del navegador y en cualquier proxy intermedio. Por eso se envía por POST, con un formulario que se envía solo al cargar. Es lo mismo que ya hacía el fragmento que les dimos; sólo cambia el nombre del campo:
<form id="ir" method="post" action="https://app.trulynolen.tech/globalink/auth/exchange">
<input type="hidden" name="token" value="<%= tokenFirmado %>">
</form>
<script>document.getElementById("ir").submit();</script>
Mientras la definitiva no exista, la misma página apunta a /globalink/pase-prueba (ver 8.10), que ya está viva y acepta este mismo formulario.
El secreto · quién lo genera, cómo llega, quién lo cuida
Lo generan ustedes, con al menos 32 bytes aleatorios (openssl rand -base64 48 sirve). Nos lo mandan por Bitwarden Send, igual que el usuario de SQL: un enlace de una sola vista. Nunca por correo, chat ni documento. De nuestro lado entra a una bóveda que lo distribuye a los dos programas que lo necesitan y a nada más; no se escribe en código, no se imprime en registros. Rotarlo es: ustedes generan uno nuevo, nos lo mandan igual, coordinamos la hora del cambio, y desde ese momento los tokens firmados con el viejo dejan de entrar.
Cómo se prueba · sin esperarnos
Apunten su formulario a https://app.trulynolen.tech/globalink/pase-prueba. Devuelve una página con las comprobaciones una por una —algoritmo, firma, vigencia, campos, base— y lo que entendió del token. Hasta que nos manden el secreto, dice «firma no verificada» y muestra el resto; en cuanto el secreto esté en nuestra bóveda, la misma dirección verifica la firma sin que ustedes cambien nada. Si ven su nombre, su correo, su idioma y su cargo correctos, su parte está terminada.
Lista de verificación de R7
| # | Qué | Cómo se confirma |
|---|---|---|
| 1 | El emisor produce un JWT HS256 con los ocho campos del ejemplo | La URL de pruebas los muestra todos en «sí» |
| 2 | Confirmado que Id es IdEmpleado y que BD vale Truly / TrulyNew / TrulyRuso | Respuesta escrita; la URL de pruebas encuentra al empleado |
| 3 | Se agregaron jti, iss y aud | Dejan de aparecer como «pendiente» en la URL de pruebas |
| 4 | El secreto llegó por Bitwarden Send | La URL de pruebas pasa de «firma no verificada» a «sí» |
| 5 | El envío es por POST | Se ve en la prueba conjunta: la dirección no lleva el token |
| 6 | La pestaña (R6) apunta a la página | Un clic desde el menú llega identificado |
R8 · La equivalencia de cargos que vamos a aplicar
Objetivo. Definir qué puede hacer en GlobalLink cada cargo de su tabla de empleados. Ya está definida y ya está cargada (4-sep-2026) en la tabla app.tni_cargo_rol de nuestro lado, así: leímos su catálogo de 43 cargos del espejo con la cantidad de empleados activos de cada uno. Diez tienen gente, y son los que van en la tabla de abajo, cada uno con su rol asignado. Los otros 33 no tienen ningún empleado activo, así que quedan sin rol: el día que TNI le asigne alguien a uno de ellos, esa persona no entrará hasta que agreguemos la fila, que es una línea en una tabla nuestra. No hace falta que preparen nada ni que respondan. La publicamos acá porque es una decisión sobre permisos de su gente y ustedes tienen que poder verla y objetarla; si una fila no corresponde, un correo y se corrige.
| IdCargo | Cargo en su sistema | Activos | Rol propuesto en GlobalLink | Qué podría hacer |
|---|---|---|---|---|
| 1 | TNI | 15 | tni_admin | Ver toda la red, administrar |
| 2 | Franquiciado | 107 | franchise_owner | Su oficina completa, repartir trabajo |
| 3 | Gerente General | 288 | franchise_admin | Su oficina completa, repartir trabajo |
| 4 | Gerente de Ventas | 28 | franchise_manager | Su oficina, repartir trabajo |
| 6 | Gerente de Operaciones | 44 | franchise_manager | Su oficina, repartir trabajo |
| 5 | Gerente Técnico | 67 | franchise_user | Ver y trabajar sus negocios |
| 7 | Administrativo | 458 | franchise_user | Ver y trabajar sus negocios |
| 8 | Vendedor | 1.011 | franchise_user | Ver y trabajar sus negocios |
| 9 | Técnico | 2.667 | sin acceso | No entra a GlobalLink |
| 10 | Otro | 71 | sin acceso | Se decide caso por caso si hace falta |
Con esta propuesta entran 2.018 personas de la red —la suma de la columna «Activos» de las filas con rol— y quedan fuera los 2.667 técnicos de campo, que no usan un sistema de ventas. Los números salen de su propia tabla de empleados, contando sólo los que no tienen fecha de baja. Su catálogo tiene 43 cargos; los 33 que no aparecen acá no tienen ningún empleado activo.
Qué hacemos con esto. La cargamos tal cual en una tabla nuestra, app.tni_cargo_rol. Un cargo que no esté en la lista no entra: el acceso se rechaza con un mensaje claro y nunca se adivina un rol. Cambiar una fila después es una línea en esa tabla, sin desplegar nada.
Lo que sí les pedimos que miren. Dos filas, porque son decisiones de negocio y no técnicas: los 2.667 técnicos de campo, que dejamos fuera porque no usan un sistema de ventas, y los 71 «Otro», que quedan fuera por precaución. Si alguno de esos grupos debería entrar, dígannos y lo cambiamos.
2.3 · Lo que TNI construye, en una sola vista
Los ocho puntos de arriba mezclan firmas, permisos y programación. Ésta es la misma lista ordenada por quién la hace y qué tipo de trabajo es, que es como se reparte en una reunión.
| Familia | Qué hace TNI | Quién | Esfuerzo y peso |
|---|---|---|---|
| Autorización | R1 · Firmar el addendum COT-0003-A1 v2, ya redactado | Dirección de TNI | Una firma · bloquea toda la escritura |
| Base de datos | R2 · Crear el usuario de escritura y darle acceso a cuatro tablas: Clientes, Contactos, Servicios y Propuestas (por qué las cuatro) | Administrador de base | 20 minutos · bloquea la primera escritura |
| Base de datos | R3 · Habilitar la regla de firewall para nuestra única dirección | Redes o el mismo DBA | 10 minutos · bloquea la primera escritura |
| Base de datos | R4 · Agregar idempotency_key a cuatro tablas por base | Administrador de base | 15 minutos · sin ella el negocio no existe como dato allá |
| Base de datos | Decisión · Las columnas de 5.1: el monto de la propuesta y la fila «GlobalLink» del catálogo | Dirección + DBA | Decisión de negocio · no bloquea |
| Dato | Nuevo 4-sep · La equivalencia de idiomas: qué idioma es cada número del 1 al 18 de su tabla. Sólo ustedes la tienen (8.9) | Quien mantenga el catálogo | Una lista · sin ella la persona entra en español |
| Desarrollo | R6 · El botón: la pestaña «Truly Nolen GlobalLink» con el logo, junto a Home y Reports | Equipo del portal | 1 hora · no bloquea |
| Desarrollo | R7 · La página del token: reconoce quién inició sesión, arma el payload, lo firma con HS256 y lo envía por POST a GlobalLink. Ya la están construyendo. | Equipo del portal | 1 a 2 horas · en curso |
| Secreto | R7 · Generar el secreto HS256 (≥32 bytes) y mandarlo por Bitwarden Send. Ya no hay tabla ni permisos que crear. | Equipo del portal | 5 minutos · cuando terminen el emisor |
| Sólo leer | R5 y R8 · Las convenciones de escritura y la equivalencia de cargos, ya decididas por nosotros | Quien quiera revisarlas | 15 minutos de lectura · no requieren respuesta |
2.4 · Lista de verificación para cerrar
Sólo las tres primeras filas tienen que ocurrir para que el proyecto avance. Las demás se marcan cuando pasen, sin fecha y sin esperar a nadie.
| # | Qué tiene que ocurrir | Cómo lo confirmamos |
|---|---|---|
| R1 | PDF del addendum firmado por ambas partes | Queda en el servidor; el registro de cada corrida lo cita |
| R2 | Recibido el 4-sep por Bitwarden Send. Pendiente: entrar a nuestra bóveda, consulta de permisos sobre las cuatro tablas y prueba negativa | Prueba negativa desde nuestro servidor (INSERT en una quinta tabla → rechazado) |
| R3 | Aviso de red habilitada | Conexión de prueba en modo sombra, sin insertar |
| R4 | Aviso de columnas e índices creados. No bloquea la primera escritura, pero sin ella el negocio no existe como dato en su base | Aparecen en el catálogo que leemos cada noche |
| R5 | Nada de su parte. Las convenciones ya están puestas. | Se ven en la primera alta de prueba, que les mostramos |
| R6 | Pestaña visible en el menú | Con el usuario de prueba que ya tenemos |
| R7 | Página que emite el JWT publicada; secreto enviado por Bitwarden Send | La URL de pruebas muestra todo en «sí», y una entrada real |
| R8 | Nada de su parte. La equivalencia ya está cargada. | Un acceso por cargo abre con el rol esperado |
2.5 · Los traspasos: qué recibe cada lado antes de poder empezar
Ningún punto de esta sección arranca en frío. Para cada uno: lo que ya tienen en la mano, lo que nos devuelven, y qué se detiene si no llega. Fíjense en la última columna: sólo tres filas detienen el proyecto; otras dos posponen una comodidad. Casi todo lo de «lo que ya les dimos» está en este documento; las tres excepciones, que van por canal aparte, son la IP de origen de R3, el logo de R6 y la dirección de pruebas de R7.
| # | Lo que ya les dimos, y dónde está | Lo que nos devuelven | Qué se detiene si no llega |
|---|---|---|---|
| R1 | El addendum COT-0003-A1 v2 redactado y el memo técnico que lo acompaña, ya entregados | El PDF firmado por ambas partes | Todo el camino de la escritura. Es la única pieza sin la cual no se escribe ni una fila, por contrato y por candado en el programa. |
| R2 | Las sentencias completas de creación del usuario y sus permisos, listas para ejecutar (abajo, en R2) | Aviso de ejecutado, y la contraseña por la bóveda de secretos que les indicamos — nunca por correo ni por chat | La conexión de escritura. Todo lo demás sigue construyéndose y probándose sin esto. |
| R3 | El puerto, el sentido y la forma de la regla. La IP /32 no va en este documento: se entrega por canal privado, junto con la contraseña de R2 | Confirmación de que la regla está aplicada | Llegar al servidor. Se comprueba en un minuto con la consulta que dejamos en R3. |
| R4 | Las cuatro ALTER TABLE y los cuatro índices, por base, con la justificación de por qué es filtrado | Nada. Es opcional. | Nada. La protección contra duplicados ya corre de nuestro lado con la marca en Observaciones. La columna es más limpia para ustedes; si no la agregan, seguimos igual. |
| R5 | Las tres convenciones ya decididas, con lo que medimos en su base y la consulta para comprobarlo | Nada. No estamos esperando respuesta. | Nada. Se escribe con lo decidido y queda registrado en cada alta. Cambiar un valor después cuesta un correo. |
| R6 | El logo en PNG y SVG, y el fragmento de HTML de la pestaña | La pestaña publicada, cuando puedan | Sólo la comodidad del clic. La gente entra igual con usuario y contraseña. |
| R7 | El script de la tabla, el código completo de la página en ASP clásico y en ASP.NET, la consulta de verificación de cada paso y una dirección de pruebas con informe en texto claro | Aviso de que la página está publicada, y en qué base escribe si no son las tres | Sólo la comodidad del clic. Nuestro lado queda construido y probado esperándola, sin fecha. |
| R8 | La equivalencia ya llena: los 10 cargos con empleados activos, con su rol y cuánta gente hay en cada uno | Nada, salvo que quieran objetar una fila | Nada. Se carga como está. Un cargo sin fila no entra: es deliberado, para que nadie reciba permisos por omisión. |
Diez tareas, cada una con archivo, columna o sentencia
Las de TNI están en la sección 2. Éstas son las nuestras.
Catalizadora · 10 tareas
- Rehacer el mapeo de alta contra el esquema realHoy omite cuatro columnas obligatorias y escribe dos nombres que no existen.
Detalle
Archivos: infra/workers/sqlserver_snapshot/lib/writeback_mapping.py (la lista COLS_CLIENTE y la función map_lead_to_tni_cliente), su espejo apps/web/lib/writeback/mapping.ts, y la tabla del addendum docs/32.
Columnas de Clientes que pasa a mandar, en este orden: Cliente · IdOficina · IdStatusCliente=5 · IdTipoCliente=1 · CuentaNacional=0 · CuentaInternacional=0 · IdVendedor · IdTipoReferencia=6 · ReferenciaOtra · Direccion · Localidad · Provincia · Telefono · Movil · Email · Web · Rubro · Identidad · ContactoAdmin · ContactoAdminEmail · ContactoAdminTelefono · FechaAlta · Observaciones. Se quitan ContactoVentas, ContactoVentasEmail y ContactoVentasTel.
Recortes al largo real: Cliente 100 · Localidad 500 · Telefono 150 · Movil 50 · Email 50 · Web 150 · Identidad 50 · ContactoAdmin 150 · ContactoAdminEmail 50 · ContactoAdminTelefono 150 · ReferenciaOtra 50. FechaAlta pasa de fecha_cierre a fecha_creacion, como fecha local de la oficina a las 00:00.
Contactos (COLS_CONTACTO): IdCliente · IdOficina · IdMotivoContacto=1 · FechaContacto · HoraContacto · FechaProgramada · HoraInicio · HoraFin · IdVendedor · VentaDirecta=1 · Observaciones. Las horas se arman como datetime(1900,1,1,hh,mm).
Servicios (COLS_SERVICIO): IdCliente · IdContacto=0 · IdVendedor · IdStatusServicio=1 · IdEtapaServicio=7 · VentaDirecta=1 · FechaAlta · FechaProgramada · HoraInicio=08:00 · HoraFin=09:00 · Observaciones. Propuestas (COLS_PROPUESTA): IdServicio · FechaPropuesta. Se quita Observaciones, que no existe en esa tabla y hoy se escribe.
La prueba que faltaba: infra/bin/eval-mapeo-datos.py hoy recorre sólo infra/scripts/populate; se le suma infra/workers/sqlserver_snapshot/lib y una segunda regla: para cada tabla que se inserta, toda columna que el catálogo declare obligatoria y no sea de identidad tiene que estar en la lista. El piso vive en infra/eval/mapeo-datos-piso.json. Y tests/probar-writeback-mapping.py deja de afirmar sólo ausencias.
- Los avisos nuevos en la colaProspecto creado, contactado, propuesta enviada, perdido, reactivado. Cita y ganado ya existen.
Detalle
Una migración nueva en infra/schemas/, la siguiente al 426, con el molde de la 302.
Disparador del alta, app.trg_lead_dueno_writeback: AFTER UPDATE OF status, assigned_rep_user_id ON app.leads. Dispara cuando la fila queda con dueño por primera vez: NEW.status = 'assigning_rep' (Rocket) o NEW.assigned_rep_user_id IS NOT NULL (vendedor), y antes no lo tenía. Va en UPDATE y no en INSERT porque el negocio se inserta en «new» y el dueño se escribe después, dentro de la misma transacción (lib/leads/dueno-al-nacer.ts). Encola evento = 'prospecto_creado' con: id_lead, prospecto_nombre, id_compania, id_contacto, id_oficina, pais_target, origen, metodo, destino, assigned_rep_user_id, fecha_creacion, ciudad, y de app.companias: industria, sitio_web, direccion, telefono, email; de app.contactos: nombre, email, telefono_e164, movil.
Disparador de hitos, app.trg_lead_hito_writeback: AFTER UPDATE OF status. contacted → contactado · negotiation → propuesta_enviada (con proposal_amount_usd) · lost → perdido (con lost_reason) · new viniendo de lost → reactivado. La cita la produce ya la 302 (lead_potencial, con scheduled_at de app.lead_appointments) y el ganado la 043 (cierre_ganado).
Un aviso por hito: un índice único parcial por evento, uq_writeback_outbox_<evento> ON app.writeback_outbox (id_lead) WHERE payload->>'evento' = '<evento>', como el uq_writeback_outbox_potencial de la 302. Reactivado admite repetición: su índice incluye la fecha.
Orden: el programa no escribe un hito de un negocio cuyo aviso de alta no esté en synced; lo deja en pending con reintento a 15 minutos.
Candado: app.dequeue_writeback_pending cambia el booleano p_incluir_potencial por p_eventos text[], por omisión sólo cierre_ganado; el cron pasa la lista completa sólo con la bandera tn:write-back encendida (lib/flags.ts, variable TN_FF_WRITE_BACK).
Pruebas: un test unitario que lee el .sql y compara los literales de los eventos con lib/writeback/potencial.ts (ya existe ese patrón para lead_potencial) y un eval «todo negocio con dueño tiene su aviso de alta».
- La regla de datos mínimosTNI exige nombre, dirección, localidad y teléfono. Un prospecto sin eso no se encola.
Detalle
En la misma migración: el CHECK de app.writeback_outbox.status (hoy pending · processing · synced · failed · dlq) suma held, y una columna held_reason text. El disparador del alta encola en held con el motivo («falta teléfono») cuando falte alguno de los tres. Un disparador sobre app.companias y app.leads.ciudad pasa la fila a pending cuando el dato llega, sea por el enriquecimiento o por la ficha. La ficha del negocio muestra el motivo mientras esté retenido.
- La consulta de duplicados antes de escribirTeléfono exacto y nombre normalizado, acotados a la oficina.
Detalle
En bin/writeback.py, antes del INSERT y después de la búsqueda por marca GLOBALLINK que ya hace: (1) teléfono en dígitos, en forma internacional y local, igual exacto contra silver.clientes.telefono y movil, en estados 1 y 5, con el criterio que lib/sales/ya-es-cliente.ts ya aplica para rotular «ya es cliente», pero acotado a la oficina en vez de al país; (2) nombre normalizado con app.normalize_brand_name() igual exacto contra el nombre del cliente en esa oficina. No se usa el parecido por trigramas de customer-mirror.ts: acusa en falso. Si hay coincidencia: no se crea; el aviso queda synced con el id encontrado, el negocio guarda ese id, y se escribe un solo renglón «Lead» en la bitácora de TNI que dice «prospecto vinculado a cliente existente desde GlobalLink».
- Guardar los números que devuelve TNICliente, contacto, servicio y propuesta.
Detalle
app.writeback_outbox ya tiene tni_cliente_id; se suman tni_contacto_id, tni_servicio_id y tni_propuesta_id, enteros. Ojo con app.leads.id_cliente_creado: es una clave foránea a silver.clientes (migración 013), así que no se puede escribir en el momento del alta: la fila recién existe en el espejo a la noche. El número queda de inmediato en el aviso y en app.leads.payload.tni_cliente_id, y el paso de vuelta completa id_cliente_creado cuando el espejo trae la fila. Una vista app.v_lead_tni junta las tres cosas para la ficha.
- La bitácora de TNI viaja completa al espejoHoy se copian 5 de sus 21 columnas.
Detalle
infra/scripts/populate/02-silver-clientes-contactos.py, el INSERT a silver.contactos: sumar IdVendedor, IdMotivoContacto, HoraContacto, FechaProgramada, HoraInicio, HoraFin y VentaDirecta. silver.contactos ya tiene id_vendedor, hora_contacto, id_motivo_contacto y venta_directa; le faltan fecha_programada, hora_inicio y hora_fin: una migración las agrega. Sin esto, la vuelta no sabe quién hizo la visita ni cuándo estaba programada.
- El paso de vuelta después del espejoFicha, bitácora y servicios contra los negocios, cada noche.
Detalle
Una función en la base, app.reconciliar_prospectos_con_tni(), que corre al final de la carga nocturna de cada base (infra/systemd/tn-espejo-base@.service; la unidad tn-espejo-diario está retirada). Para cada negocio con número de cliente en TNI: aplica las reglas de la sección «Cruce de vuelta» leyendo silver.clientes, silver.contactos y silver.servicios; escribe la historia en app.lead_activity con actor_label = 'TNI' y los tipos que ya existen (stage_change, field_change, note, call_logged); cambia app.leads.status sólo hacia adelante, respetando la máquina de estados de lib/leads/stage-transitions.ts; ignora todo renglón de bitácora cuyas observaciones empiecen con GLOBALLINK. Deja su conteo en el log del espejo, para distinguir «corrió y no había cambios» de «no corrió».
- Operar el programa de escrituraTopes, reintentos, auditoría, alerta.
Detalle
Ya existen infra/systemd/tn-writeback.service y .timer, apagados a propósito. El timer corre cada 15 minutos; al encender, con --aplicar sólo si existe el documento firmado en el servidor y la variable TN_WRITEBACK_APLICAR=1. Alerta por infra/bin/tn-alert-email.sh si hay más de 50 avisos pendientes durante más de 30 minutos, que es lo que fija el addendum. Cada intento queda en app.writeback_log.
- El punto de entrada del accesoMás el logo, el fragmento y la especificación para su equipo.
Detalle
Ruta nueva apps/web/app/auth/exchange/route.ts, sólo POST, declarada pública en apps/web/proxy.ts. Con el basePath de producción sirve /globalink/auth/exchange — una sola «l» en globalink, que es el prefijo real de la aplicación y no una errata.
Qué hace, en el orden de la sección 8.4: comprueba que el token llegó por POST con forma de JWT y algoritmo HS256 —cualquier otro, incluido none, se rechaza sin leerlo—; verifica la firma contra el secreto de la bóveda en tiempo constante; comprueba iat y exp con ±60 s de reloj y tope de cinco minutos; inserta jti —o la huella del token— en app.sso_pases_vistos en la misma transacción, para que no sirva dos veces; exige que BD sea una de las tres bases productivas; busca el Id en dbo.Empleados de esa base con el usuario lector user_globallink que ya existe, y de ahí toma cargo, oficina, país y estado; traduce el cargo con app.tni_cargo_rol y el idioma con la tabla de 8.9; crea o actualiza public.users por external_sub = 'tni:' || IdEmpleado; firma la sesión y redirige. Cada intento, bueno o malo, deja su motivo en auditoría con la huella del token, nunca el token entero.
Configuración: ninguna variable de entorno nueva. No hay emisor, ni llave, ni dirección remota que configurar: todo sale de la conexión de lectura que ya está en uso. Prueba de punta a punta insertando una fila a mano en la tabla de TNI y enviando esa credencial, sin necesidad de que su página exista todavía. Entrega a TNI: el logo en PNG y SVG, el fragmento HTML de la pestaña, la dirección de pruebas con el informe en texto claro, y la especificación versionada en specs/ACCESO-SSO.md.
Migraciones: dos tablas nuevas (app.sso_pases_vistos, app.tni_cargo_rol), una columna nueva (public.users.id_empleado_tni, con índice único) y un motivo nuevo de revocación de sesión, employee_offboarded, que hoy no existe en app.user_sessions.
- Corregir el número de estado en el código de GlobalLinkHoy lee el 4 como «Potencial» y es «Cancelado por el cliente».
Detalle
Dónde vive el número equivocado: en la vista materializada gold.customer_mirror_opportunities, cuyo CASE etiqueta el 4 como «oportunidad entre países» y manda el 5, el potencial real, a «otro»; eso sí cambia el resultado de esa vista. En apps/web/lib/sales/customer-mirror.ts el 4 está en el comentario de la línea 140, y la lógica de la línea 182 sólo distingue el 1, así que ahí no cambia el comportamiento. También en el caso icp-004 de apps/web/evals/golden/icp.jsonl y en docs/57. Una migración recrea la vista con el 5, y un test unitario fija el catálogo: 1 · 2 · 3 · 4 · 5 con sus etiquetas.
Negocio, compañía y contacto: qué es cada una, cómo se enlazan y a dónde va cada campo
En GlobalLink un prospecto no es una sola fila. La compañía es la empresa; el contacto es una persona de esa empresa; el negocio es la oportunidad que un vendedor trabaja con esa empresa, con una persona señalada. Una compañía puede tener varios contactos y varios negocios a lo largo del tiempo. TNI no separa así: su ficha de cliente junta empresa y hasta tres personas, y la oportunidad vive en la bitácora y en el servicio.
| Entidad · campo en GlobalLink | Qué es y de dónde sale | A dónde va en TNI | Cuándo |
|---|---|---|---|
| Compañía · app.companias · una por empresa y país; nace en el padrón o al crear el negocio (lib/leads/compania-del-negocio.ts) | |||
| nombre · nombre_norm | Razón social o marca; el normalizado es la llave que evita duplicar la misma empresa | Clientes.Cliente (100) | alta |
| pais_iso · oficina_id | País de dos letras y oficina que la encontró | no viaja: el país va en la oficina y en la base | — |
| industria | Giro, del hallazgo o del enriquecimiento | Clientes.Rubro | alta |
| direccion | Dirección física, del hallazgo | Clientes.Direccion · obligatoria | alta |
| telefono | Teléfono principal de la empresa | Clientes.Telefono (150) · obligatoria | alta |
| Correo general de la empresa | Clientes.Email (50) | alta | |
| sitio_web | Sitio | Clientes.Web (150) | alta |
| Número de WhatsApp descubierto para la empresa (mig 378) | no viaja: Clientes no tiene columna de WhatsApp | — | |
| enrichment_status · enrichment_payload · enriched_at | Estado y resultado del enriquecimiento; de acá salen ciudad, departamento e identidad fiscal cuando el padrón las trae | Clientes.Localidad · Provincia · Identidad | alta |
| creado_por_sub | Quién la dio de alta (mig 408) | no viaja | — |
| Contacto · app.contactos · varias personas por compañía; el negocio señala una con id_contacto (mig 407) | |||
| nombre · cargo | La persona y su puesto | Clientes.ContactoAdmin (150) · el cargo no tiene columna | alta |
| Correo de la persona | Clientes.ContactoAdminEmail (50) | alta | |
| telefono_e164 | Teléfono de la persona, en formato internacional | Clientes.ContactoAdminTelefono (150) · en formato local | alta |
| movil · movil_tiene_whatsapp | Móvil editado a mano y si tiene WhatsApp (mig 409) | Clientes.Movil (50) | alta |
| whatsapp · linkedin_url · instagram_url · x_url · fuente_url | Canales descubiertos y de dónde salió el dato (mig 378, 319) | no viajan | — |
| los demás contactos de la compañía | Personas no señaladas por el negocio | no viajan: la ficha tiene tres roles fijos | — |
| Negocio · app.leads · la oportunidad; señala compañía (id_compania, mig 369) y contacto (id_contacto, mig 407) | |||
| id_lead | Identificador del negocio | Clientes.Observaciones y Contactos.Observaciones, como marca GLOBALLINK | alta y cada hito |
| id_oficina | Oficina dueña, del alcance del usuario | IdOficina en las cuatro tablas, y la base de destino | siempre |
| assigned_rep_user_id | El vendedor dueño → número de empleado de TNI | Clientes.IdVendedor · Contactos.IdVendedor · Servicios.IdVendedor | siempre |
| status | Etapa: new · contacted · appointment_scheduled · negotiation · won · lost · tni_synced | no viaja como valor: cada cambio produce un renglón en Contactos; Clientes queda en estado 5 | cada hito |
| origen · metodo | Fuente del dato (13 valores) y lente con que se descubrió (mig 377) | Clientes.Observaciones · IdTipoReferencia 6 + ReferenciaOtra | alta |
| fecha_creacion | Cuándo nació | Clientes.FechaAlta | alta |
| ciudad | Localidad, del enriquecimiento | Clientes.Localidad (500) · obligatoria | alta |
| app.lead_appointments.scheduled_at | La cita agendada | Contactos.FechaProgramada · HoraInicio · HoraFin | cita |
| proposal_amount_usd | Monto propuesto | Contactos.Observaciones «propuesta enviada · USD …» | propuesta |
| fecha_cierre | Cuándo se ganó | Servicios.FechaAlta · FechaProgramada · Propuestas.FechaPropuesta | ganado |
| lost_reason | Por qué se perdió | Contactos.Observaciones «perdido · motivo» | perdido |
| id_cliente_creado · payload.tni_cliente_id | El número que TNI devolvió: la llave de la vuelta | ← Clientes.IdCliente | vuelta |
| score · win_probability · next_action · notas | Datos de trabajo del vendedor | no viajan | — |
Campo por campo, de GlobalLink a TNI
Columnas, tipos, largos y obligatoriedad leídos hoy del catálogo del SQL Server de TNI en sus tres bases productivas. Las convenciones (qué valor ponen, qué llenan siempre) se midieron sobre los 1.268 prospectos de la oficina de Guatemala, sus 1.364 gestiones de venta de los últimos 365 días y sus 919 servicios de los últimos 180 días.
| En GlobalLink | Transformación | En TNI | Estado |
|---|---|---|---|
| Alta del prospecto → dbo.Clientes · una fila por negocio · las marcadas «obligatoria» no admiten nulo | |||
| app.leads.prospecto_nombretexto | Recorte a 100 caracteres | Clientenvarchar(100) · obligatoria | va |
| app.leads.id_oficinaentero · lleva la base adentro | Base = id ÷ 1.000.000.000 (0 Truly · 1 TrulyNew · 2 TrulyRuso). IdOficina = resto | IdOficinaint · obligatoria | va |
| constante | Siempre 5. TNI: 1 Active · 2 Inactive · 3 Cancelled by TN · 4 Cancelled x customer · 5 Potential | IdStatusClienteint · obligatoria | arreglar hoy no se manda |
| constante | 1, «Creative / venta activa», porque el prospecto sale de prospección activa. Es una decisión nuestra, no la práctica de la 679: ahí el 72 % de los potenciales está en 2 «Office / venta receptiva» y el 24 % en 1. Se lo informamos a TNI en R5; si prefieren el 2, se cambia con un correo | IdTipoClienteint · obligatoria | arreglar hoy no se manda decidido |
| constantes | 0 y 0 por omisión: no sabemos si es cuenta nacional. TNI llena los dos en el 100 % de sus prospectos y marca cuenta nacional en el 41 % | CuentaNacional · CuentaInternacionalsmallint | crear acá |
| app.leads.assigned_rep_user_iduuid · el dueño del negocio | → número de empleado de TNI. Llega solo con el acceso desde su sistema (la credencial resuelve el empleado, ver sección 8). Antes de eso, el cruce es por correo contra silver.empleados. | IdVendedorint → dbo.Empleados · TNI lo llena en el 97 % | crear acá |
| destino del negociorocket · vendedor | 6 «Other / Otro» + texto «TN GlobalLink · Rocket» o «· Manual». Mejor aún: una fila «GlobalLink» nueva en el catálogo, que agrega TNI | IdTipoReferencia · ReferenciaOtraint · nvarchar(50) | crear acá catálogo |
| app.companias.direcciontexto | Tal cual. Si falta, el prospecto no se encola | Direccionnvarchar(max) · obligatoria | arreglar hoy no se manda |
| app.leads.ciudadtexto · del enriquecimiento | Tal cual. Si falta, el prospecto no se encola | Localidadnvarchar(500) · obligatoria en Truly y TrulyNew, opcional en TrulyRuso | arreglar hoy no se manda |
| enriquecimientodepartamento | Tal cual, si existe. El aviso de hoy no lo lleva: llegaría nulo | Provincianvarchar(50) | arreglar |
| app.companias.telefonotexto | Formato local del país, sin prefijo internacional, como guarda TNI. Si falta, el prospecto no se encola | Telefononvarchar(150) · obligatoria | va |
| app.contactos.moviltexto | Formato local, recorte a 50 | Movilnvarchar(50) | va |
| app.companias.emailtexto | Recorte a 50 caracteres: el campo de TNI es corto | Emailnvarchar(50) | va |
| app.companias.sitio_webtexto | Recorte a 150. El aviso de hoy no lo lleva: llegaría nulo | Webnvarchar(150) | arreglar |
| app.companias.industriatexto | Tal cual. El aviso de hoy no lo lleva: llegaría nulo | Rubronvarchar(max) · TNI lo llena en el 3 % | arreglar |
| enriquecimiento del padrónNIT, RFC, RUT, CUIT | Sólo si el registro oficial lo trajo. TNI la llena en el 100 % de sus prospectos; cuando no la tengamos escribimos el texto fijo «C/F», decidido en R5 | Identidadnvarchar(50) | decidido |
| app.contactos.nombre · email · telefono_e164la persona que decide | Al contacto administrativo, que es el que TNI llena en el 100 % de sus prospectos; el de ventas sólo en el 7 %. El teléfono pasa a formato local, como el de la empresa | ContactoAdmin · ContactoAdminEmail · ContactoAdminTelefononvarchar(150) · (50) · (150) | arreglar hoy va a ContactoVentas |
| app.leads.fecha_creacionfecha y hora con zona | Fecha local de la oficina a las 00:00, que es como TNI la guarda. Hoy el código usa la fecha de cierre | FechaAltadatetime | arreglar |
| app.leads.id_lead · origen · metodometodo existe desde la migración 377 | GLOBALLINK:<id_lead> · origen=… · metodo=… | Observacionesnvarchar(max) | va |
| app.writeback_outbox.id_outboxuuid del aviso | Tal cual | idempotency_keyuniqueidentifier · columna nueva con índice único · opcional, sólo si aplican R4 | crear allá |
| Cada hito → dbo.Contactos · un renglón «Lead» por aviso, como TNI registra sus visitas de venta | |||
| app.leads.id_cliente_creado | Resto de la división por base | IdClienteint · obligatoria | crear acá |
| app.leads.id_oficina | Misma regla | IdOficinaint · obligatoria | va |
| constante | Siempre 1 «Lead / Visita de venta». Los otros motivos del catálogo son «Callback» (2) y «Felicitaciones» (4); el reclamo va en otra columna, IdTipoReclamo, que queda vacía | IdMotivoContactoint · obligatoria | arreglar hoy no se manda |
| fecha del hito | Fecha local a las 00:00 | FechaContactodatetime · obligatoria | va |
| hora del hito | Hora local sobre la fecha base 1900-01-01, que es la convención de TNI | HoraContactodatetime · obligatoria | arreglar hoy no se manda |
| app.lead_appointments.scheduled_aten la cita; en los demás hitos, la fecha del hito | Fecha a las 00:00; inicio y fin sobre 1900-01-01; por omisión 08:00 a 09:00, como TNI. TNI los llena en el 100 % | FechaProgramada · HoraInicio · HoraFindatetime | crear acá |
| app.leads.assigned_rep_user_id | Número de empleado, como arriba | IdVendedorint · TNI 100 % | crear acá |
| constante | Siempre 1. TNI: 1.358 de 1.364 | VentaDirectasmallint | crear acá |
| tipo de aviso + detalle | GLOBALLINK:<id_lead>:<hito> · detalle | Observacionesnvarchar(max) en Truly y TrulyNew, ntext en TrulyRuso | va |
| app.writeback_outbox.id_outbox | Tal cual | idempotency_keycolumna nueva · opcional (R4) | crear allá |
| Ganado → dbo.Servicios y dbo.Propuestas · diez columnas obligatorias en Servicios sin contar la de identidad; el código de hoy manda dos | |||
| app.leads.id_cliente_creado | Resto de la división por base | Servicios.IdClienteint · obligatoria | va |
| constante | 0. En los 919 servicios recientes de la 679 el contacto enlazado es 0; la columna es obligatoria, así que ése es el valor que TNI escribe. No se leyeron claves foráneas | Servicios.IdContactoint · obligatoria | arreglar |
| app.leads.assigned_rep_user_id | Número de empleado | Servicios.IdVendedorint · obligatoria | arreglar |
| constantes | Estado 1 «Active»; etapa 7 «Proposal Accepted, Schedule Service», la etapa en la que están los servicios de clientes potenciales de la 679 (21 de 23); venta directa 1 | IdStatusServicio · IdEtapaServicio · VentaDirectaint · int · smallint · obligatorias | arreglar |
| app.leads.fecha_cierre | Fecha a las 00:00; programada el mismo día; horas 08:00 a 09:00 sobre 1900-01-01 | FechaAlta · FechaProgramada · HoraInicio · HoraFindatetime · obligatorias | arreglar |
| IdServicio devuelto | Tal cual | Propuestas.IdServicioint · la única obligatoria en Truly y TrulyRuso; TrulyNew exige además IdTipoEdificio, Contrato y Periodico | va TrulyNew |
| marca de trazabilidad | No existe columna de observaciones en Propuestas: la marca va en el servicio y en la clave anti-duplicados | Propuestas.ObservacionesNO EXISTE · hoy el código la escribe | arreglar |
CodigoCliente lo numera la oficina (14 % de sus prospectos lo tienen). ContactoVentas y ContactoTecnico son otros roles. Naturaleza, IdZona, Sucursal los completa la oficina cuando los conoce. El país no existe como columna: va implícito en la oficina y en la base.5.1 · Lo nuestro que no tiene columna en su base
La tabla de arriba dice a dónde aterriza cada dato que sí tiene destino. Ésta dice lo contrario: lo que existe en GlobalLink y hoy no tiene dónde vivir en el sistema de TNI. Salió de comparar el mapeo contra el programa que de verdad corre, el 4 de septiembre de 2026.
| En GlobalLink | Dónde cae hoy en TNI | Qué se pierde | Qué habría que crear allá |
|---|---|---|---|
| app.leads.proposal_amount_usdel monto de la propuesta | En ninguna partedbo.Propuestas sólo admite IdServicio y FechaPropuesta | El valor del negocio no existe del lado de TNI. Sus informes no pueden sumar cuánto trajo GlobalLink, ni comparar una propuesta nuestra con una suya | crear allá una columna de monto en dbo.Propuestas |
| app.leads.win_probabilityprobabilidad de cierre | En ninguna parte | No se puede pronosticar desde su lado: la propuesta llega sin señal de qué tan probable era | crear allá columna, o se acepta que ese dato viva sólo acá |
| app.leads.id_leadel identificador del negocio | Dentro del texto de ObservacionesGLOBALLINK:<id> · origen=… · metodo=… | El negocio no es consultable ni indexable. Nuestra protección contra duplicados depende de buscar una cadena dentro de texto libre | crear allá idempotency_key, que es exactamente R4 |
| La trazabilidad de la propuesta | No cabedbo.Propuestas no tiene columna de observaciones | La propuesta queda sin ningún rastro de haber nacido en GlobalLink. Es el único caso donde el dato se pierde del todo | crear allá idempotency_key en dbo.Propuestas es el único lugar posible |
| Origen y métodoRocket o vendedor | ReferenciaOtratexto libre, bajo IdTipoReferencia = 6 «Other» | No se puede contar cuántos negocios trajo GlobalLink, ni separar los de Rocket de los manuales, sin leer prosa | crear allá una fila «GlobalLink» en su catálogo de tipos de referencia |
Lo que TNI cambia, y cómo llega a GlobalLink
El espejo nocturno ya copia la ficha de clientes, la bitácora de contactos y los servicios de TNI, completos, cada noche. Existe un modo que trae sólo las filas que cambiaron, con la marca que pone el motor de SQL Server en cada modificación; se midió en agosto y está apagado desde el 12 de agosto por un defecto en su marca de avance. Lo que falta para la vuelta es el paso siguiente: comparar lo que llegó contra los negocios y aplicar el cambio.
| Cambió en TNI | Llega por el espejo a | Efecto en GlobalLink | Estado |
|---|---|---|---|
| La llave que une los dos lados | |||
| Clientes.IdClientelo asignó TNI al crear | silver.clientes.id_clientecon la base sumada | Se busca el negocio por app.leads.id_cliente_creado. Sin esa columna escrita no hay vuelta posible | crear acá |
| La ficha del cliente | |||
| Clientes.IdStatusCliente5 → 1 · TNI lo activó | silver.clientes.id_status_cliente | El negocio pasa a ganado si no lo estaba, y luego a sincronizado. Fecha de cierre: la de TNI | crear acá |
| Clientes.IdStatusCliente→ 3 «Cancelled by TN» · → 4 «Cancelled x customer» | silver.clientes.id_status_cliente | El negocio pasa a perdido, con el motivo que TNI eligió | crear acá |
| Clientes.IdStatusCliente→ 2 «Inactive» | silver.clientes.id_status_cliente | Se anota en la línea de tiempo y se avisa al gerente: un prospecto no debería volverse inactivo | crear acá |
| Clientes.IdVendedorcambió | silver.clientes.id_vendedor | Se reasigna el dueño por el número de empleado. Si ese empleado no tiene usuario en GlobalLink, se avisa al gerente de la oficina | crear acá |
| Clientes.Telefono · Movil · Email · Direccion · Localidad | silver.clientes.telefono · movil · email · direccion · localidad | Se actualizan la empresa y el contacto en GlobalLink. TNI manda sobre los datos del cliente una vez creado | crear acá |
| Clientes.ContactoAdmin · Email · Telefono | silver.clientes.contacto_admin · _email · _tel | Se actualiza la persona de contacto del negocio | crear acá |
| La bitácora de contactos | |||
| Contactosrenglón nuevo para ese IdCliente, sin la marca GLOBALLINK | silver.contactoshoy trae 5 columnas; sumar IdVendedor, IdMotivoContacto, HoraContacto, FechaProgramada, HoraInicio, HoraFin, VentaDirecta | Entra a la línea de tiempo como «visita o llamada registrada en TNI por …». Si el negocio estaba en «nuevo», pasa a «contactado» | crear acá |
| Contactosrenglón con la marca GLOBALLINK | silver.contactos | Se ignora: lo escribimos nosotros. Sin esta regla, cada hito volvería como si TNI lo hubiera anotado | crear acá |
| Los servicios | |||
| Servicios.IdEtapaServicio→ 8 «Service Started» | silver.servicios.id_etapa_servicio | El negocio pasa a ganado y sincronizado, aunque la ficha siga en 5 | crear acá |
| Servicios.IdStatusServicio→ 3 o 4, cancelado | silver.servicios.id_status_servicio | El negocio pasa a perdido, con el motivo | crear acá |
| Serviciosservicio nuevo para ese IdCliente sin la marca | silver.servicios | Se contrató por fuera de GlobalLink: el negocio pasa a ganado con la fecha del servicio | crear acá |
Un vendedor, un prospecto, catorce pasos
Carlos vende en la oficina de Guatemala, que ya tiene 1.268 clientes en estado Potencial inscritos a mano en el sistema de TNI. Así es su día con los dos sistemas conectados.
- 1Carlos entra al TNI Management System, como todos los días
Con su usuario de siempre. Ese usuario vive en la tabla de empleados de TNI, con su oficina y su cargo. Nada cambia en su sistema.
TNI ya existe - 2Hace clic en la pestaña «Truly Nolen GlobalLink»
Su sistema guarda una credencial de un solo uso en su propia base, junto al número de empleado de Carlos, y la manda a GlobalLink. Dura dos minutos y sirve una sola vez.
HomeReports ▾
Truly Nolen GlobalLink ↗TNI pone la pestaña y la página que guarda la credencial · Catalizadora entrega el logo, el fragmento y la especificación - 3GlobalLink lo recibe ya identificado
GlobalLink busca la credencial en la base de TNI con el usuario de lectura que ya tiene, comprueba que siga vigente y que no se haya usado, lee de ahí mismo su oficina y su cargo, y abre su tablero de Guatemala, oficina 679, con permisos de vendedor. Como todo se lee de la base de TNI, GlobalLink ya sabe quién es Carlos: no hace falta cruzar nada a mano.
Entró desde el sistema de gestión.Catalizadora construye el punto de entrada y las once comprobaciones - 4Prospecta
En «Prospectar» busca por sector, por zona o deja que el sistema le proponga. Encuentra el Hotel Los Volcanes, con dirección, teléfono, correo y la persona que decide.
Catalizadora ya existe - 5Decide cómo trabajarlo: Modo Manual o Truly Rocket
Modo Manual: se lo queda él. Truly Rocket: el bot hace los primeros toques y le entrega la cita. En los dos casos el negocio nace con dueño.
Hotel Los VolcanesHotelería · Zona 10, Guatemala · +502 2xxx xxxxContacto: María Pérez, gerente de operacionesEnviar a Truly RocketModo ManualCatalizadora ya existe - 6En segundos nace el negocio en GlobalLink; en minutos, el cliente en TNI
GlobalLink crea la empresa, la persona y el negocio, y deja el aviso en la cola. El programa de escritura elige la base de Guatemala, comprueba que el hotel no exista ya en la cartera de la 679, y crea la ficha en «Clientes», que es donde Guatemala da de alta a sus prospectos en 2026 (su módulo «Gestión de Ventas / Prospectos» no recibe altas desde noviembre de 2025). La crea exactamente como TNI crea las suyas: estado 5 Potencial, Carlos como vendedor, la persona como contacto administrativo, referencia «Otro: TN GlobalLink», y el tipo de cliente 1 «Creative / venta activa», decidido en R5. TNI devuelve el número de cliente y queda guardado en el negocio.
Catalizadora el aviso, el programa, el mapeo · TNI el usuario de escritura y el acceso de red - 7Carlos lo ve en TNI, en su lista de clientes
Vuelve a su sistema, filtra por estado Potencial y ahí está, con su nombre como vendedor. Igual que los otros 1.268 potenciales de la oficina.
ID Oficina Código Cliente Vendedor Localidad Status 588xxx GUATE — HOTEL LOS VOLCANES
14 calle 3-20 zona 10MORALES, C. Guatemala Potencial 587997 GUATE — CLINICA SANTA LUCIA
Av. Reforma 12-01 zona 9RAMIREZ, A. Guatemala Potencial Reconstrucción con las columnas y colores reales de la pantalla «Clientes» de su sistema: ID, Oficina, Código, Cliente con dirección, Vendedor, Localidad y Status en amarillo para Potencial. El código lo asigna la oficina.TNI su pantalla de siempre; nada que construir - 8Primer contacto
Carlos llama o escribe desde GlobalLink; si eligió Rocket, el bot manda el primer correo. En TNI aparece un renglón en la bitácora del cliente con motivo «Lead», que es como TNI registra sus gestiones de venta: las 1.364 del último año en la 679 llevan ese motivo, 1.358 de ellas marcadas como venta directa.
Catalizadora - 9Agenda la visita técnica
La agende él o la agende el bot. En TNI aparece un renglón «Lead» con fecha programada y hora de inicio y fin, que es exactamente la forma en que TNI anota una visita de venta. Es el momento en que el prospecto pasa a «potencial» según lo acordado en la reunión del 10 de agosto, que quedó escrito en la migración 302.
Date Reason Scheduled Sales Person Notes 04/09/2026 Lead 04/09/2026 08:00–09:00 Carlos M. GLOBALLINK · contactado · correo 05/09/2026 Lead 09/09/2026 10:00–11:00 Carlos M. GLOBALLINK · cita agendada Catalizadora - 10Hace la visita y anota el resultado en TNI, como hace hoy
Carlos registra la visita en el sistema de gestión, no en GlobalLink. Esa noche, el espejo trae ese renglón y GlobalLink lo pone en la línea de tiempo del negocio.
- 9 sepVisita registrada en TNI por Carlos M.«Recorrido de cocina y bodega. Interés en plan mensual.»
- 5 sepCita agendada para el 9 de septiembre, 10:00
- 4 sepCorreo enviado · primer contacto
- 4 sepNegocio creado · Modo Manual · cliente TNI n.º 588.214
TNI Carlos usa su sistema · Catalizadora el paso de vuelta que lee el espejo y lo aplica - 9 sep
- 11Manda la propuesta
Desde GlobalLink, con el monto. En TNI: otro renglón «Lead» en la bitácora con «propuesta enviada · USD 1.800 anuales».
Catalizadora - 12Gana
Carlos cierra el negocio en GlobalLink. El programa crea en TNI el servicio en la etapa «Propuesta aceptada, programar servicio», la etapa en la que están los servicios de los clientes potenciales de la oficina, y la propuesta colgando de él. Aparece en el reporte Sales > Services > List de la oficina. La ficha sigue en Potencial: activarla es de TNI.
ServiceId IdCustomer Customer Sales Person Address Zone StatusService StartDate EndDate Frequency Valor 1035xxx 588xxx HOTEL LOS VOLCANES MORALES C. 14 CALLE 3-20 ZONA 10 ZONA 10 Active 09/15/2026 09/14/2027 Monthly 150.00 Reconstrucción sobre la pantalla real capturada el 3 de septiembre con el usuario de prueba: las once columnas y el encabezado son los del reporte; la fila es de ejemplo.Catalizadora ya existe, ajustando las columnas obligatorias - 13TNI programa, inicia y factura
Con su flujo de siempre: el servicio pasa a «iniciado» y la ficha de Potencial a Activo. Nadie de Catalizadora toca esa ficha ni ese servicio.
TNI ya existe - 14Esa noche, GlobalLink cierra el ciclo
El espejo trae el cambio y el negocio queda como sincronizado con TNI. Si Carlos no lo había cerrado, se cierra solo. Si en cambio TNI cancela el servicio o da de baja la ficha, el negocio pasa a perdido, con el motivo que TNI puso.
Catalizadora
El acceso, para construir y para auditar
La especificación completa del método de R7 tal como quedó el 4 de septiembre de 2026: un JWT firmado con HS256 que emite el portal de TNI y que GlobalLink verifica. Dice qué pieza es de cada lado, qué viaja y qué no, las comprobaciones en orden, y quién sostiene cada garantía de seguridad. La versión anterior —una credencial de un solo uso en una tabla de su base— queda registrada en index.v20.html; se cambió a pedido de TNI y con nuestro acuerdo.
8.1 · Las partes, y de quién es cada una
| Pieza | De quién | Qué hace |
|---|---|---|
| La pestaña en el menú (R6) | TNI | Lleva a la página del token. Un enlace con el logo. |
| La página que emite el token (R7) | TNI | Lee quién está en sesión, arma el payload, lo firma con HS256 y lo envía por POST a GlobalLink. Vive dos minutos. |
| El secreto compartido | TNI genera · los dos custodian | Lo que hace válida la firma. Ver 8.5. |
| El punto de entrada /globalink/auth/exchange | Catalizadora | Verifica el token, hace las comprobaciones de 8.4, crea o actualiza al usuario, abre la sesión y redirige. |
| El registro de tokens vistos (app.sso_pases_vistos) | Catalizadora | Garantiza «una sola vez» de nuestro lado, por jti o por huella del token. TNI no necesita borrar nada. |
| La equivalencia de cargos (app.tni_cargo_rol, R8) | Catalizadora | De IdCargo al rol de GlobalLink. Ya está cargada. |
| La equivalencia de idiomas (8.9) | Catalizadora | Del número de columna de TNI al idioma de GlobalLink. |
| La dirección temporal de pruebas (8.10) | Catalizadora | Recibe el mismo token y muestra qué entendió, sin abrir sesión. Ya está viva. |
8.2 · El token: qué lleva y qué no
Lleva lo que su ejemplo trae —identificador, nombre, apellido, correo, idioma, base, emisión y vencimiento— y, si lo agregan, jti, iss y aud. El contrato campo por campo está en R7.
No lleva contraseñas, ni el cargo, ni la oficina, ni el país: esos tres los leemos de dbo.Empleados con el usuario lector, porque es la fuente y porque así su página no tiene que saber nada de permisos.
8.3 · Cómo viaja
Por POST, en un campo token, desde la página de TNI a https://app.trulynolen.tech/globalink/auth/exchange. Nunca en la dirección. El navegador de la persona hace el viaje; nuestro servidor y el de TNI no se hablan entre sí para esto. Dos minutos de vida, un solo uso.
8.4 · Qué hace GlobalLink al recibirlo, en orden
Las once comprobaciones. Las cinco primeras son propias del token; de la sexta en adelante son las mismas que tendría cualquier forma de entrar, y son las que hacen cumplir la equivalencia de cargos de R8. Ninguna consulta escribe en la base de TNI.
| # | Comprobación | Si falla · motivo registrado |
|---|---|---|
| 1 | Llega por POST, en token, con forma de JWT (tres partes) y cabecera alg = HS256. Cualquier otro algoritmo se rechaza sin mirar el contenido. | 400 token_malformado |
| 2 | La firma coincide con el secreto compartido (HMAC-SHA256 en tiempo constante). | 401 firma_invalida |
| 3 | iat y exp son enteros; ahora está entre ellos con ±60 s de tolerancia; la vida (exp − iat) no supera 5 minutos aunque venga firmada así. | 401 token_vencido · token_del_futuro · vida_excesiva |
| 4 | No se vio antes: se inserta jti —o, si no viene, la huella SHA-256 del token— en app.sso_pases_vistos dentro de la misma transacción; si choca con la llave única, es un reenvío o un intento de repetición. | 401 token_ya_usado |
| 5 | Si vienen iss y aud, valen tni-management y tn-globallink. BD es una de las tres bases productivas; TrulyGestion se rechaza. | 401 emisor_o_destino_incorrecto · base_no_productiva |
| 6 | El Id existe en dbo.Empleados de esa base, con FechaBaja IS NULL e Inactivo = 0. | 403 empleado_inactivo |
| 7 | Su IdPais se traduce a un país activo de la plataforma. El código de país de TNI no es ISO en 13 de sus 239 países —Guatemala es «GTB»—, por eso la traducción la hacemos nosotros con su propia tabla. | 403 pais_no_habilitado |
| 8 | Su IdOficina existe en silver.oficinas y pertenece a ese país. | 403 oficina_no_habilitada |
| 9 | Su IdCargo tiene fila con rol en app.tni_cargo_rol. Técnicos y «Otro» tienen fila sin rol: no entran, y no se les crea usuario. | 403 cargo_sin_acceso |
| 10 | El correo del token coincide con el de Empleados. Si no, se anota en auditoría y manda el de su base. | — (se anota, no bloquea) |
| 11 | Se crea o actualiza public.users por external_sub = 'tni:' || IdEmpleado, con rol, oficina, país, idioma e id_empleado_tni; se firma la sesión; se redirige a la pantalla de inicio en el idioma de 8.9. | — (éxito · 302) |
8.5 · Quién garantiza cada propiedad de seguridad
| Propiedad | La garantiza | Cómo |
|---|---|---|
| Nadie fabrica un token | los dos | La custodia del secreto. ≥32 bytes aleatorios; de nuestro lado en una bóveda que lo distribuye sólo a los dos programas que lo usan; de su lado, fuera del código y de los registros. Es la única garantía que depende de los dos. |
| Nadie altera un token | Catalizadora | La firma se verifica en tiempo constante antes de leer nada. |
| Un token no se usa dos veces | Catalizadora | app.sso_pases_vistos, llave única, misma transacción. |
| Un token viejo no sirve | TNI emite · Catalizadora aplica | Dos minutos de vida desde su emisor; tope de cinco del nuestro. |
| Nadie lee el token en tránsito | los dos | HTTPS de punta a punta, y POST para que no quede en registros. |
| Un empleado dado de baja no entra | Catalizadora | Comprobación 6, contra su tabla, en cada entrada. |
| Cada persona ve sólo lo que su cargo permite | Catalizadora | Comprobación 9 y la tabla de R8, publicada y cargada. |
| Todo intento queda registrado | Catalizadora | Auditoría con motivo, empleado, base, hora y huella del token; nunca el token entero ni el secreto. |
8.6 · Qué ve la persona si algo falla
Una página en el idioma de la persona, o en español si no se conoce, con un texto por motivo, sin jerga y sin datos técnicos: «Este acceso venció; vuelve al sistema de gestión y haz clic otra vez» · «Tu oficina todavía no está habilitada en GlobalLink; avisa a tu gerente» · «Tu cargo no tiene un rol asignado en GlobalLink; avisa a TNI». Siempre con el enlace de vuelta al sistema de gestión. Los detalles técnicos quedan en la auditoría, no en pantalla.
8.7 · Altas, bajas y cambios de usuarios
Alta: al primer clic. No hace falta pre-cargar a nadie. Baja: en cuanto FechaBaja tenga valor en su tabla, la persona deja de entrar y sus sesiones abiertas se revocan. Cambio de cargo u oficina: se aplica en la siguiente entrada, porque cada entrada lo vuelve a leer. Nada de esto requiere avisarnos.
8.8 · Qué entrega cada lado, y cómo se prueba
TNI entrega · tres cosas
| # | Qué | Cómo lo confirmamos |
|---|---|---|
| 1 | La página que emite el token (R7), apuntando primero a la URL de pruebas | La URL de pruebas muestra todo en «sí» |
| 2 | El secreto, por Bitwarden Send | La firma pasa de «pendiente» a «sí» |
| 3 | La pestaña (R6) | Captura del menú |
Catalizadora entrega · todo lo demás
El punto de entrada con las once comprobaciones, el registro de tokens vistos, los ocho textos de error en diez idiomas, el alta automática, la auditoría, la especificación actualizada, y la prueba conjunta: una llamada de menos de una hora con el usuario de prueba de TNI, que recorre los ocho casos de error y un acceso correcto.
8.9 · El idioma: ya encontramos su tabla, y no hace falta que nos la manden
Pedido de TNI del 4 de septiembre: que la persona llegue a GlobalLink en el idioma que ya eligió en el sistema de gestión, usando «una tabla de 1 a 18». La buscamos en su base el mismo día y la encontramos, así que en vez de pedírsela se la mostramos, para que sólo tengan que confirmarla.
Cómo viaja. En el campo Idioma del token, como cadena de dos dígitos ("01"). Igual que el resto del token, va por POST y nunca en la dirección: lleva nombre y correo, y una dirección queda escrita en registros de servidores, historiales y bitácoras intermedias.
La equivalencia, leída de su base el 4 de septiembre de 2026. Sólo falta que confirmen que la leímos bien.
| Nº | Idioma que contiene | En GlobalLink | Estado |
|---|---|---|---|
| 01 | Inglés | en | listo |
| 02 | Español | es | listo es nuestro idioma fuente |
| 03 | Portugués | pt | listo único con revisión humana además del español |
| 04 | Turco | tr | parcial algunas pantallas caen al inglés |
| 05 | Español · segunda variante | es | listo lo tratamos igual que el 02 |
| 06 | Francés | fr | parcial |
| 07 | Portugués · segunda variante | pt | listo igual que el 03 |
| 09 | Ruso | — | no lo tenemos entraría en español · ver abajo |
| 11 | Italiano | it | parcial |
| 12 | Ruso · segunda variante | — | no lo tenemos |
| 13 | Árabe | ar | parcial |
| 08 · 10 · 14 · 15 · 16 · 17 | Inglés sin traducirel texto quedó en inglés en esas columnas | en | listo los tratamos como inglés |
8.10 · La dirección temporal para que TNI pruebe desde ya
Ya está viva, y desde el 4 de septiembre acepta el JWT tal como su equipo lo está construyendo.
https://app.trulynolen.tech/globalink/pase-prueba ← POST, campo token (para pruebas también acepta ?token=)
Qué contesta hoy, antes del secreto. Muestra todo lo que el token trae con un aviso visible de «firma no verificada». En cuanto el secreto esté en nuestra bóveda, la misma dirección verifica la firma sin que ustedes cambien nada. Si el ejemplo que nos mandaron se envía tal cual, marca en «no» la base —porque TrulyGestion es la copia de pruebas— y en «pendiente» jti, iss y aud: es la lista de lo que falta, en pantalla.
Qué necesitó para existir. Nada de su parte: corre en el mismo servidor que ya lee su base —el de su lista blanca— con el usuario lector que ya nos dieron. Se apaga el día que entre la definitiva.
↑ volver al resumenPara quien necesite el fondo
Los dos sentidos, en un diagrama
Lo que GlobalLink escribe en minutos, y lo que TNI cambia vuelve cada noche
El prospecto vive en la ficha de clientes de TNI en estado 5, Potencial. Cada avance es un renglón «Lead» en su bitácora. Al ganar, nace el servicio en la etapa que TNI usa para eso. Lo que TNI cambie vuelve por el espejo.
Qué detona qué
Cada momento del negocio, y lo que produce en TNI
Cada detonante deja un aviso en la cola de salida. El programa de escritura lo toma, elige la base por la oficina y escribe. Si falla, reintenta; si se repite, no duplica.
| Momento en GlobalLink | Aviso en la cola | Qué se escribe en TNI | Qué vuelve |
|---|---|---|---|
| El negocio queda con dueño Rocket o vendedor, en la misma transacción del alta | prospecto_creadonuevo | Alta en dbo.Clientes: estado 5, tipo 1, vendedor, contacto administrativo, referencia, y todos los campos del cruce de ida | IdCliente → app.leads.id_cliente_creado |
| Primer contacto el negocio pasa a «contactado» | contactadonuevo | Renglón «Lead» en dbo.Contactos: fecha, hora, vendedor, canal en observaciones | IdContacto, para auditoría |
| Cita agendada por el bot o a mano | lead_potencialya existe | Renglón «Lead» con FechaProgramada, HoraInicio y HoraFin de la visita | IdContacto → se guarda: es el contacto de la venta |
| Propuesta enviada el negocio pasa a «negociación» | propuesta_enviadanuevo | Renglón «Lead» con el monto en observaciones | IdContacto |
| Ganado | cierre_ganadoya existe | Alta en dbo.Servicios en etapa 7 «Propuesta aceptada, programar servicio», estado activo, que es la etapa en la que están 21 de los 23 servicios de clientes potenciales de la 679; alta en dbo.Propuestas colgando del servicio. La ficha sigue en 5. | IdServicio, IdPropuesta. Cuando TNI inicia y factura, el cambio vuelve por el espejo y cierra el ciclo. |
| Perdido con motivo | perdidonuevo | Renglón «Lead» con el motivo | Nada. TNI decide si da de baja la ficha. |
| Reactivado de perdido vuelve a nuevo | reactivadonuevo | Renglón «Lead». No se crea otra ficha: se reutiliza el IdCliente guardado. | Nada |
Nuestro lado en Supabase: dónde vive cada cosa
Dónde vive cada cosa en Supabase
GlobalLink corre sobre un único proyecto de Supabase, con esquemas separados por función. Esta es la ruta de un prospecto por ese proyecto, de ida y de vuelta, con la tabla y la columna exactas de cada paso.
| Esquema · tabla · columna | Para qué sirve en este flujo | Existe | Cambio |
|---|---|---|---|
| El negocio y sus entidades | |||
| app.leads.id_leaduuid | El identificador del prospecto. Viaja a TNI en la marca de observaciones | sí | — |
| app.leads.status · assigned_rep_user_id · id_oficina · pais_target · origen · metodo · ciudad · fecha_creacion · fecha_cierre · proposal_amount_usd · lost_reason | Lo que leen los disparadores para armar cada aviso. metodo entró con la migración 377 y no está en el volcado base más viejo | sí mig 013, 040, 377 | — |
| app.leads.id_compania → app.companiasnombre, industria, sitio_web, direccion, telefono, email | La empresa. Alimenta Cliente, Rubro, Web, Direccion, Telefono, Email | sí mig 319, 369 | — |
| app.leads.id_contacto → app.contactosnombre, email, telefono_e164, movil | La persona. Alimenta ContactoAdmin, su correo y su teléfono, y Movil | sí mig 319, 407, 409 | — |
| app.lead_appointments.scheduled_at | La visita. Alimenta FechaProgramada, HoraInicio y HoraFin del renglón de cita | sí | — |
| app.leads.id_cliente_creadobigint · clave foránea a silver.clientes | El número de cliente en TNI, con la base sumada. Se completa cuando el espejo trae la fila | sí mig 013 | escribirla |
| app.leads.payload.tni_cliente_id | El mismo número, de inmediato, sin esperar al espejo | sí | — |
| La cola de salida | |||
| app.writeback_outboxid_outbox, id_lead, payload, status, attempts, next_retry_at, tni_cliente_id, synced_at | Un aviso por hito. payload->>'evento' dice cuál. id_outbox es la clave de idempotencia que viaja a TNI | sí mig 043, 143, 302 | status «held», held_reason, tni_contacto_id, tni_servicio_id, tni_propuesta_id |
| app.writeback_log | Cada intento, con resultado, duración y error | sí | — |
| app.trg_lead_won_writeback · app.trg_lead_potencial_writeback | Los dos disparadores de hoy: ganado y cita | sí mig 043, 302 | — |
| app.trg_lead_dueno_writeback · app.trg_lead_hito_writeback | Los disparadores nuevos: alta con dueño, contactado, propuesta, perdido, reactivado | no | migración nueva |
| app.dequeue_writeback_pending · app.reap_stale_writeback | Reclamo atómico con plazo de 15 minutos y recuperación de huérfanos | sí | p_eventos text[] |
| La vuelta | |||
| silver.clientesid_cliente, id_status_cliente, id_vendedor, telefono, movil, email, direccion, localidad, contacto_admin, contacto_admin_email, contacto_admin_tel, fecha_baja | La ficha de TNI, cada noche | sí | — |
| silver.contactosid_cliente, id_vendedor, id_motivo_contacto, fecha_contacto, hora_contacto, venta_directa, observaciones | La bitácora de TNI. Hoy el populate llena 5 columnas | sí | fecha_programada, hora_inicio, hora_fin + populate |
| silver.serviciosid_cliente, id_status_servicio, id_etapa_servicio, fecha_alta | Los servicios de TNI | sí | — |
| app.reconciliar_prospectos_con_tni() | Aplica el cruce de vuelta sobre app.leads, app.lead_activity, app.companias y app.contactos | no | migración nueva |
| app.lead_activitytype: stage_change, field_change, note, call_logged · actor_label | La línea de tiempo del negocio, donde entra lo que TNI anotó | sí | — |
| Personas, oficinas y catálogos | |||
| public.usersid, external_sub, email, name, role, oficina_id, pais_id | El directorio de usuarios. external_sub recibe el número de empleado de la credencial | sí | id_empleado_tni |
| app.tni_cargo_rol · app.sso_pases_vistos | La equivalencia cargo → rol informada en R8, y las credenciales ya canjeadas | no | migración nueva |
| silver.oficinasid_oficina, id_pais · silver.paises_opsiso_alpha_2 · silver.paises_del_grupo() | La oficina y su país; la validación de que el país del negocio corresponde a la oficina | sí mig 200 | — |
| silver.empleadosid_empleado, email, id_oficina, id_cargo | Los empleados de TNI, para el cruce por correo mientras no esté el acceso | sí | — |
| silver.aux_status_clientes · aux_tipos_clientes · aux_motivos_contactos · aux_tipos_referencias · aux_status_servicios · aux_etapas_servicios | Los catálogos de TNI en 16 idiomas. De acá salen las etiquetas que muestra GlobalLink | sí | — |
| lib/flags.ts · tn:write-backTN_FF_WRITE_BACK | La bandera que deja salir los avisos hacia TNI. Hoy apagada | sí | — |
Cómo se inscribe: las sentencias
Las cuatro sentencias que escribe el programa
Sólo altas, con parámetros, en la base que corresponde a la oficina. El envoltorio de conexión rechaza cualquier otra instrucción antes de abrir la conexión. Las columnas son las reales; los valores fijos son los que TNI usa.
Sobre la última columna de cada sentencia. idempotency_key es la columna opcional de R4. El programa no la escribe a ciegas: al arrancar lee el catálogo de columnas de cada base y arma el INSERT con ella sólo si existe. Si TNI no aplica R4, las cuatro sentencias salen sin esa columna y sin ese parámetro, y la protección contra duplicados queda a cargo de la marca GLOBALLINK:{id_lead} en Observaciones, que se escribe siempre. Abajo se muestran con la columna puesta, que es la variante completa.
-- 1 · el alta del prospecto · una sola vez por negocio INSERT INTO dbo.Clientes ([Cliente], [IdOficina], [IdStatusCliente], [IdTipoCliente], [CuentaNacional], [CuentaInternacional], [IdVendedor], [IdTipoReferencia], [ReferenciaOtra], [Direccion], [Localidad], [Provincia], [Telefono], [Movil], [Email], [Web], [Rubro], [Identidad], [ContactoAdmin], [ContactoAdminEmail], [ContactoAdminTelefono], [FechaAlta], [Observaciones], [idempotency_key]) OUTPUT INSERTED.IdCliente VALUES (@nombre, @id_oficina, 5, 1, 0, 0, @id_empleado, 6, 'TN GlobalLink · Rocket', @direccion, @localidad, @provincia, @telefono, @movil, @email, @web, @rubro, @identidad, @contacto_nombre, @contacto_email, @contacto_telefono, @fecha_alta, 'GLOBALLINK:' + @id_lead + ' · origen=' + @origen + ' · metodo=' + @metodo, @id_aviso); -- 2 · cada hito · un renglón «Lead» en la bitácora INSERT INTO dbo.Contactos ([IdCliente], [IdOficina], [IdMotivoContacto], [FechaContacto], [HoraContacto], [FechaProgramada], [HoraInicio], [HoraFin], [IdVendedor], [VentaDirecta], [Observaciones], [idempotency_key]) OUTPUT INSERTED.IdContacto VALUES (@id_cliente, @id_oficina, 1, @fecha, @hora, @fecha_visita, @hora_inicio, @hora_fin, @id_empleado, 1, 'GLOBALLINK:' + @id_lead + ':' + @hito + ' · ' + @detalle, @id_aviso); -- 3 · al ganar · el servicio en la etapa donde TNI crea los suyos, y la propuesta colgando INSERT INTO dbo.Servicios ([IdCliente], [IdContacto], [IdVendedor], [IdStatusServicio], [IdEtapaServicio], [VentaDirecta], [FechaAlta], [FechaProgramada], [HoraInicio], [HoraFin], [Observaciones], [idempotency_key]) OUTPUT INSERTED.IdServicio VALUES (@id_cliente, 0, @id_empleado, 1, 7, 1, @fecha_cierre, @fecha_cierre, '1900-01-01 08:00', '1900-01-01 09:00', 'GLOBALLINK:' + @id_lead + ':servicio', @id_aviso); -- Propuestas NO tiene columna Observaciones: la traza va en el servicio y en la clave INSERT INTO dbo.Propuestas ([IdServicio], [FechaPropuesta], [idempotency_key]) OUTPUT INSERTED.IdPropuesta VALUES (@id_servicio, @fecha_cierre, @id_aviso); -- en TrulyNew la tabla exige además IdTipoEdificio, Contrato y Periodico: se resuelve con TNI antes de escribir ahí
Primero por la marca GLOBALLINK en observaciones. Después por teléfono exacto o nombre normalizado contra la cartera de esa oficina en el espejo, con el criterio que GlobalLink ya usa para rotular «ya es cliente», acotado a la oficina. Si existe, se enlaza el número y no se crea.
El aviso lleva su propio identificador. Con la clave única que agrega TNI, el motor rechaza el segundo intento. Mientras no exista, la marca en observaciones cumple la misma función.
Cinco reintentos con espera creciente. Un fallo por dato inválido no se reintenta: queda marcado y visible. Cada intento, con su resultado, se guarda en la bitácora de escritura.
En horario laboral de la oficina; treinta fuera de horario. Topes mensuales acordados: cinco mil clientes, doce mil renglones de bitácora, ocho mil servicios, tres mil propuestas.
Quién manda cuando los dos lados cambian lo mismo
Cuando los dos lados cambian lo mismo
Estado de la ficha, vendedor asignado, teléfono, correo, dirección, etapa del servicio. Lo que TNI cambie ahí se adopta en GlobalLink y queda anotado como cambio hecho en TNI.
Contactado, cita, negociación: esas etapas no existen en la ficha de TNI. Se registran allá como renglones de bitácora, pero se deciden acá.
Un negocio se cierra como ganado cuando el vendedor lo cierra en GlobalLink o cuando TNI activa la ficha o inicia el servicio, lo que pase primero. Sólo TNI pasa la ficha de Potencial a Activo.
GlobalLink sólo agrega filas en TNI. Si algo tiene que cambiar en una ficha ya creada, lo cambia TNI y vuelve por el espejo.
Qué se verificó hoy, y contra qué
Lo que esta página afirma sobre TNI se leyó de su base, no de un documento
| Afirmación | Cómo se comprobó | Resultado |
|---|---|---|
| Guatemala vive en la base Truly, y el número 679 se repite en otra base | Conteo en vivo de la oficina 679 en las tres bases | Truly: 14.941 clientes, última alta ayer. TrulyNew: el mismo número 679 es otra oficina, de otro país, con 701 clientes. TrulyRuso: no existe. Por eso el número de oficina nunca viaja sin su base |
| El estado de prospecto es el 5 | Lectura del catálogo de estados en el espejo, tres idiomas | 1 Active · 2 Inactive · 3 Cancelled by TN · 4 Cancelled x customer · 5 Potential. La documentación y una vista de GlobalLink decían 4. |
| TNI ya inscribe prospectos así | Conteo por estado en la oficina 679, leído en vivo del SQL Server; el espejo de anoche da 1.269 | 1.268 clientes en estado 5; 2.484 activos; 11.124 inactivos |
| Columnas, tipos, largos y obligatoriedad de las cuatro tablas | Catálogo del SQL Server en las tres bases productivas, con las columnas de identidad; Guatemala vive en Truly | En Truly, la base de Guatemala, sin contar las columnas de identidad: Clientes 79 columnas, 7 obligatorias. Contactos 21, 5. Servicios 78, 10. Propuestas 174, 1. TrulyNew agrega columnas (Clientes 80, Contactos 22, Servicios 80, Propuestas 178) y en Propuestas exige tres más |
| Qué llena TNI en un prospecto | Porcentaje de llenado por columna sobre los 1.268 potenciales de la 679 | Nombre, dirección, localidad, teléfono, contacto administrativo, identidad y fecha al 100 %. Vendedor 97 %. Contacto de ventas 7 %. Móvil 33 %, correo 28 % |
| Cómo registra TNI una gestión de venta | 1.364 renglones de bitácora con motivo «Lead» de los últimos 365 días en la 679 | 1.358 con motivo «Lead» y venta directa 1. Fecha, hora, visita programada y vendedor al 100 %. Observaciones 17 % |
| En qué etapa están los servicios de TNI | 919 servicios dados de alta en los últimos 180 días en la 679, cruzados con el estado del cliente | Sólo 23 pertenecen a clientes potenciales, y de ésos 21 están en etapa 7. Del total, la mayoría ya está en etapa 8, iniciado. Es la etapa actual, no la de creación. El contacto enlazado es 0 en el 100 %, y como la columna es obligatoria, ese 0 es lo que TNI escribe |
| Los prospectos de Guatemala entran en «Clientes», no en el módulo «Prospectos» de su sistema | Altas por mes en ClientesTemporarios (el módulo Prospectos) y en Clientes para la 679 | Prospectos: 409 filas en la 679, la última alta en noviembre de 2025, 7 % convertidas. Clientes: entre 55 y 134 altas por mes en 2026. Medido en vivo el 3 de septiembre, desde el servidor que aloja el espejo |
| La hora se guarda sobre la fecha 1900-01-01 | Formato de los últimos renglones de bitácora y de servicios | Confirmado: 1900-01-01 08:00:00 |
| El mapeo de escritura actual fallaría | Comparación del código contra el catálogo | Omite IdStatusCliente, IdTipoCliente, Direccion y Localidad, obligatorias; escribe ContactoVentasTel, que no existe; en Contactos omite HoraContacto e IdMotivoContacto; en Servicios manda 2 de las 10 obligatorias; en Propuestas escribe Observaciones, que no existe |
| Los usuarios del sistema de gestión son empleados | Catálogo de dbo.Empleados | Tiene usuario y contraseña como columnas obligatorias, junto con país, oficina y cargo. La credencial se resuelve contra dbo.Empleados por IdEmpleado |
| El espejo puede traer sólo lo que cambió | Medición del 5 de agosto sobre dos fotos completas, en el repositorio, y el estado actual en el README del worker | La marca de motor capturó el 100 % de los cambios en clientes, contactos y servicios en Truly y TrulyNew. El modo está apagado desde el 12 de agosto; hoy el espejo es completo |