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 | 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 identifica al usuario y guarda la credencial | una página nueva 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.
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 identifica al usuario 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 nuevo del lado de TNI: una tabla y una página.
Esto no bloquea nada, y no hay fecha comprometida. Mientras la página no exista, cada persona entra a GlobalLink con su usuario y contraseña, que es como se entra hoy. Lo que aporta es la comodidad de un clic y que nadie tenga que recordar una segunda contraseña. Lo describimos con este nivel de detalle —el código completo, en las dos tecnologías de su portal— justamente para que cuando su equipo tenga el hueco, sean dos o tres horas y no un proyecto.
El método elegido: credencial de un solo uso. La página genera un número aleatorio, lo guarda en una tabla junto con el número de empleado y una caducidad de dos minutos, y lo envía a GlobalLink. GlobalLink lo busca en esa misma tabla con el usuario de lectura que ya tiene, comprueba que siga vigente y que no se haya usado antes, y abre la sesión. Sin criptografía, sin librerías, sin llaves, sin permisos nuevos.
Lo elegimos por tres razones: funciona igual en ASP clásico y en ASP.NET, y su portal tiene las dos cosas; no obliga a su equipo a manejar llaves ni rotaciones; y no requiere que nos den ningún permiso adicional, porque nuestro usuario lector ya puede leer cualquier tabla de la base. Si prefieren un token firmado, las dos variantes están al final de este requerimiento.
Paso 1 · Crear la tabla
Dónde. En las tres bases productivas: Truly, TrulyNew y TrulyRuso. Si su portal usa una sola base para todos los países, créenla sólo ahí y avísennos cuál es.
Qué se crea. Una tabla y un índice. Ninguna otra cosa.
| Columna | Tipo | Para qué |
|---|---|---|
| Token | UNIQUEIDENTIFIER NOT NULL | La credencial. La genera el motor con NEWID(). Es la clave primaria |
| IdEmpleado | INT NOT NULL | Quién pidió el acceso. Sale de la sesión del portal |
| Correo | NVARCHAR(100) NULL | Nuevo (4-sep). El correo del empleado, como lo tiene el portal. Es con lo que lo damos de alta en GlobalLink la primera vez |
| IdIdioma | INT NULL | Nuevo (4-sep). El número de idioma de su tabla de 1 a 18. Con eso GlobalLink abre en el idioma que la persona ya eligió en su sistema. Ver 8.9 |
| Creado | DATETIME2(0) NOT NULL | Cuándo se pidió. Lo pone el motor |
| Vence | DATETIME2(0) NOT NULL | Dos minutos después. Pasado eso, la credencial no sirve |
| Origen | NVARCHAR(45) NULL | La dirección desde la que se pidió. Opcional, ayuda a auditar |
USE [Truly]; -- después repetir igual en [TrulyNew] y en [TrulyRuso]
GO
CREATE TABLE [dbo].[GlobalLinkAccesos] (
[Token] UNIQUEIDENTIFIER NOT NULL CONSTRAINT [DF_GLA_Token] DEFAULT NEWID(),
[IdEmpleado] INT NOT NULL,
[Correo] NVARCHAR(100) NULL,
[IdIdioma] INT NULL,
[Creado] DATETIME2(0) NOT NULL CONSTRAINT [DF_GLA_Creado] DEFAULT SYSUTCDATETIME(),
[Vence] DATETIME2(0) NOT NULL,
[Origen] NVARCHAR(45) NULL,
CONSTRAINT [PK_GlobalLinkAccesos] PRIMARY KEY CLUSTERED ([Token])
);
GO
CREATE NONCLUSTERED INDEX [IX_GlobalLinkAccesos_Vence]
ON [dbo].[GlobalLinkAccesos] ([Vence]);
GO
Verificación del paso 1. Tiene que devolver siete filas, una por columna:
SELECT c.name AS columna, t.name AS tipo, c.max_length, c.is_nullable
FROM sys.columns c
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE c.object_id = OBJECT_ID('dbo.GlobalLinkAccesos')
ORDER BY c.column_id;
Cuánto ocupa. Una fila por acceso, unos 60 bytes. Con 2.018 empleados entrando dos veces por día son unas 4.000 filas diarias, que la propia página va borrando. La tabla nunca pasa de unos pocos megabytes.
Paso 2 · Darle permiso de escritura al usuario del portal
Qué usuario es. El que ya usa el sistema de gestión para leer y escribir en la base: el que figura en la cadena de conexión del portal. No es el nuestro. Si no saben cuál es, esta consulta lo muestra mientras alguien navega el sistema:
SELECT DISTINCT s.login_name, s.program_name, DB_NAME(s.database_id) AS base
FROM sys.dm_exec_sessions s
WHERE s.is_user_process = 1 AND DB_NAME(s.database_id) IN ('Truly','TrulyNew','TrulyRuso');
El permiso. Sólo sobre la tabla nueva: insertar, leer y borrar. No hace falta modificar. El SELECT es imprescindible aunque la página no consulte nada, porque el INSERT usa OUTPUT INSERTED.Token para recuperar la credencial recién creada, y sin permiso de lectura sobre esa tabla el motor rechaza la cláusula:
GRANT INSERT, SELECT, DELETE ON [dbo].[GlobalLinkAccesos] TO [<el usuario del portal>]; GO
Verificación del paso 2. Ejecutar conectado como ese usuario; tiene que insertar y devolver un identificador:
INSERT INTO dbo.GlobalLinkAccesos (IdEmpleado, Vence)
OUTPUT INSERTED.Token
VALUES (22059, DATEADD(minute, 2, SYSUTCDATETIME()));
-- borrar la fila de prueba
DELETE FROM dbo.GlobalLinkAccesos WHERE IdEmpleado = 22059;
Sobre nuestro lado: no hace falta darnos nada. Nuestro usuario user_globallink pertenece al rol db_datareader y tiene permiso de consulta a nivel de base de datos, verificado el 3 de septiembre de 2026. Ese permiso alcanza automáticamente a cualquier tabla nueva.
Paso 3 · Averiguar dos cosas de su sistema
La página necesita dos datos que sólo su equipo conoce. Este paso no es programar: es abrir el código del portal y anotarlos.
3a · Cómo identifica el sistema al empleado que tiene la sesión abierta. En la cabecera de cada pantalla aparece el nombre de la persona, por ejemplo «PABLO ESTRADA». Ese texto sale de una variable de sesión. Búsquenla en el archivo que dibuja esa cabecera, que en la instalación actual es /proposals/TG/includes/inc_menu_superior.asp, o en la página de inicio de sesión, buscando Session(. Puede llamarse Session("IdEmpleado"), Session("Usuario") u otra cosa.
Los dos casos posibles, y qué hacer en cada uno:
| Lo que guarda la sesión | Qué usa la página |
|---|---|
| El número de empleado, por ejemplo 22059 | Se usa directo. Es el caso simple. |
| El nombre de usuario con el que entró, por ejemplo Pablo2026 | La página lo convierte con una consulta a su propia tabla de empleados: SELECT IdEmpleado FROM dbo.Empleados WHERE Usuario = @usuario AND FechaBaja IS NULL. El filtro por fecha de baja no es opcional: es lo que hace que un empleado dado de baja deje de recibir credenciales. El código de abajo trae esta variante lista. |
3b · La cadena de conexión que usa el portal. En ASP clásico suele estar en un Application("…") definido en global.asa, o en un archivo de configuración incluido en todas las páginas. En ASP.NET está en web.config, dentro de connectionStrings. La página nueva usa exactamente la misma; no se crea ninguna conexión nueva ni se guarda ninguna contraseña más.
Paso 4 · Crear la página
Dónde. En la misma carpeta que el resto del sistema, con el nombre globallink.asp si la hacen en ASP clásico, o globallink.aspx si la hacen en ASP.NET. No hace falta cambiar ninguna configuración de IIS: es una página más de la aplicación que ya existe.
Versión ASP clásico. Completa. Las dos líneas con la flecha son las únicas que hay que ajustar con lo que anotaron en el paso 3:
<%@ Language="VBScript" %>
<%
Option Explicit
Response.Buffer = True
Response.AddHeader "Cache-Control", "no-store"
Response.AddHeader "Referrer-Policy", "no-referrer"
Dim usuario, idEmpleado, cn, cmd, rs, token
' ►►► AJUSTAR (paso 3a): la variable de sesión que ya usa el sistema.
' Si guardan el número de empleado, usen esa y salten el bloque de conversión.
usuario = Session("Usuario")
If IsEmpty(usuario) Or usuario = "" Then
Response.Redirect "login.asp"
End If
Set cn = Server.CreateObject("ADODB.Connection")
' ►►► AJUSTAR (paso 3b): la misma cadena de conexión que usa el resto del sistema
cn.Open Application("ConnectionString")
' ── conversión de nombre de usuario a número de empleado ────────────────
' Si la sesión ya trae el número, borren este bloque y hagan:
' idEmpleado = Session("IdEmpleado")
Set cmd = Server.CreateObject("ADODB.Command")
Set cmd.ActiveConnection = cn
cmd.CommandText = "SELECT IdEmpleado FROM dbo.Empleados " & _
"WHERE Usuario = ? AND FechaBaja IS NULL"
cmd.Parameters.Append cmd.CreateParameter("u", 200, 1, 50, usuario) ' adVarWChar
Set rs = cmd.Execute
If rs.EOF Then
rs.Close : cn.Close
Response.Write "<p>Su usuario no tiene un empleado activo asociado. " & _
"Avise a la administración de TNI.</p>"
Response.End
End If
idEmpleado = rs("IdEmpleado")
rs.Close : Set rs = Nothing : Set cmd = Nothing
' ── limpieza de credenciales viejas · no falla si no hay ninguna ────────
cn.Execute "DELETE FROM dbo.GlobalLinkAccesos " & _
"WHERE Vence < DATEADD(day, -1, SYSUTCDATETIME())"
' ── generar la credencial ───────────────────────────────────────────────
Set cmd = Server.CreateObject("ADODB.Command")
Set cmd.ActiveConnection = cn
cmd.CommandType = 1 ' adCmdText
cmd.CommandText = "SET NOCOUNT ON; " & _
"INSERT INTO dbo.GlobalLinkAccesos (IdEmpleado, Vence, Origen) " & _
"OUTPUT INSERTED.Token " & _
"VALUES (?, DATEADD(minute, 2, SYSUTCDATETIME()), ?)"
cmd.Parameters.Append cmd.CreateParameter("id", 3, 1, 4, CLng(idEmpleado)) ' adInteger
cmd.Parameters.Append cmd.CreateParameter("ip", 200, 1, 45, _
Request.ServerVariables("REMOTE_ADDR")) ' adVarWChar
Set rs = cmd.Execute
token = rs("Token")
rs.Close : Set rs = Nothing : Set cmd = Nothing
cn.Close : Set cn = Nothing
%>
<!doctype html>
<html><head><meta charset="utf-8"><title>Entrando a Truly Nolen GlobalLink…</title></head>
<body style="font-family:Arial,Helvetica,sans-serif;padding:40px;color:#333">
<p>Entrando a Truly Nolen GlobalLink…</p>
<form id="f" method="post"
action="https://app.trulynolen.tech/globalink/auth/exchange">
<input type="hidden" name="credencial" value="<%= token %>" />
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById('f').submit();</script>
</body></html>
Versión ASP.NET. Mismo comportamiento. Archivo globallink.aspx.cs:
using System;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
public partial class GlobalLink : System.Web.UI.Page
{
protected string Token = "";
protected void Page_Load(object sender, EventArgs e)
{
Response.AppendHeader("Cache-Control", "no-store");
Response.AddHeader("Referrer-Policy", "no-referrer");
// ►►► AJUSTAR (paso 3a)
object usuario = Session["Usuario"];
if (usuario == null || string.IsNullOrEmpty(usuario.ToString()))
{ Response.Redirect("~/login.asp"); return; }
// ►►► AJUSTAR (paso 3b)
string cs = ConfigurationManager.ConnectionStrings["Truly"].ConnectionString;
using (var cn = new SqlConnection(cs))
{
cn.Open();
int idEmpleado;
using (var q = new SqlCommand(
"SELECT IdEmpleado FROM dbo.Empleados WHERE Usuario = @u AND FechaBaja IS NULL", cn))
{
q.Parameters.Add("@u", SqlDbType.NVarChar, 50).Value = usuario.ToString();
object r = q.ExecuteScalar();
if (r == null)
{ Response.Write("Su usuario no tiene un empleado activo asociado."); Response.End(); return; }
idEmpleado = Convert.ToInt32(r);
}
using (var limpia = new SqlCommand(
"DELETE FROM dbo.GlobalLinkAccesos WHERE Vence < DATEADD(day, -1, SYSUTCDATETIME())", cn))
limpia.ExecuteNonQuery();
using (var ins = new SqlCommand(
"INSERT INTO dbo.GlobalLinkAccesos (IdEmpleado, Vence, Origen) " +
"OUTPUT INSERTED.Token " +
"VALUES (@id, DATEADD(minute, 2, SYSUTCDATETIME()), @ip)", cn))
{
ins.Parameters.Add("@id", SqlDbType.Int).Value = idEmpleado;
ins.Parameters.Add("@ip", SqlDbType.NVarChar, 45).Value =
(object)Request.UserHostAddress ?? DBNull.Value;
Token = ((Guid)ins.ExecuteScalar()).ToString();
}
}
}
}
Y el globallink.aspx que lo acompaña. Va con CodeFile, que es lo que corresponde cuando se sueltan los dos archivos en la carpeta sin recompilar nada. Si su portal se compila a una DLL (un Web Application Project), cambien CodeFile por CodeBehind y recompilen; con el atributo equivocado el servidor responde Could not load type 'GlobalLink':
<%@ Page Language="C#" AutoEventWireup="true"
CodeFile="globallink.aspx.cs" Inherits="GlobalLink" %>
<!doctype html>
<html><head><meta charset="utf-8"><title>Entrando a Truly Nolen GlobalLink…</title></head>
<body style="font-family:Arial,Helvetica,sans-serif;padding:40px;color:#333">
<p>Entrando a Truly Nolen GlobalLink…</p>
<form id="f" method="post"
action="https://app.trulynolen.tech/globalink/auth/exchange">
<input type="hidden" name="credencial" value="<%= Token %>" />
<noscript><button type="submit">Continuar</button></noscript>
</form>
<script>document.getElementById('f').submit();</script>
</body></html>
Paso 5 · Probar la página sola, antes de conectarla
Este paso se hace sin nosotros y sin salir de su red. Sirve para confirmar que la sesión y la conexión funcionan antes de involucrar a GlobalLink.
- Cambiar el destino del formulario, de forma temporal
Reemplacen la línea action="https://app.trulynolen.tech/…" por action="globallink.asp" y agreguen debajo del formulario, también temporal: <p>Credencial generada: <%= token %></p> (en la versión ASP.NET, <%= Token %>). Quiten el <script> que envía solo, para poder ver la pantalla.
- Entrar al sistema con un usuario real y abrir la página
Con una sesión iniciada, ir a /proposals/TG/globallink.asp. Tiene que aparecer la credencial en pantalla, con forma de 3f2504e0-4f89-41d3-9a0c-0305e82c3301.
- Comprobar que la fila quedó bien
En la base, esta consulta tiene que devolver la fila recién creada, con el número de empleado correcto y una caducidad dos minutos en el futuro:
SELECT TOP 5 a.Token, a.IdEmpleado, e.Nombre, e.Apellido, a.Creado, a.Vence, a.Origen FROM dbo.GlobalLinkAccesos a JOIN dbo.Empleados e ON e.IdEmpleado = a.IdEmpleado ORDER BY a.Creado DESC; - Volver a dejar la página como estaba
Restaurar el destino real, quitar la línea que muestra la credencial y devolver el <script>. Importante: la credencial no debe quedar visible en pantalla en la versión definitiva.
Paso 6 · Conectar con GlobalLink, primero contra pruebas
Nos avisan y les habilitamos una dirección de pruebas. Cambian el destino del formulario por esa dirección y abren la página con una sesión real. La respuesta es una pantalla en texto claro que dice qué comprobación pasó y cuál falló, con este formato:
1 · credencial bien formada .......... sí 2 · encontrada en la base de TNI ..... sí (base: Truly) 3 · vigente .......................... sí (vence en 108 segundos) 4 · no usada antes ................... sí 5 · dentro del tope de 5 minutos ..... sí (creada hace 12 segundos) 6 · empleado activo .................. sí (IdEmpleado 22059) 7 · país habilitado .................. sí (IdPais 244 → Guatemala) 8 · oficina habilitada ............... sí (IdOficina 679) 9 · cargo con rol asignado ........... sí (IdCargo 8 → vendedor) RESULTADO ............................ acceso concedido
Son las nueve comprobaciones que su equipo puede reproducir con una credencial de prueba. Las dos últimas de la sección 8.4 no aparecen en el informe porque sólo se ven con una cuenta real: la 10 crea o actualiza la cuenta —y rechaza con cuenta_desactivada si estaba dada de baja de nuestro lado— y la 11 abre la sesión. Cuando las nueve líneas den «sí», cambian el destino por la dirección definitiva y quedó. Nosotros confirmamos del otro lado que la sesión abrió con el usuario correcto.
Paso 7 · Agregar la pestaña
Es el requerimiento R6: una entrada más en la barra de menú que apunte a la página del paso 4, con la extensión que corresponda a la tecnología que hayan elegido. Con eso el circuito queda cerrado para el usuario final.
Si algo falla · qué significa cada error
| Lo que ve el equipo | Qué significa | Cómo se arregla |
|---|---|---|
| Se redirige a login.asp | La variable de sesión del paso 3a no es la correcta, o está vacía | Verificar el nombre exacto en el archivo de la cabecera del portal |
| Invalid object name 'dbo.GlobalLinkAccesos' | La tabla no existe en la base a la que se conecta el portal | Crearla también en esa base; el paso 1 pide las tres |
| The INSERT permission was denied | Falta el permiso del paso 2 para el usuario del portal | Ejecutar el GRANT del paso 2 en esa base |
| The SELECT permission was denied | El GRANT del paso 2 se ejecutó sin SELECT. La cláusula OUTPUT INSERTED lo necesita. | Volver a ejecutar el GRANT completo del paso 2 |
| «Su usuario no tiene un empleado activo asociado» | El nombre de usuario de la sesión no existe en dbo.Empleados, o tiene fecha de baja | Revisar el empleado; si su login no usa esa tabla, avisarnos y ajustamos la consulta |
| La pantalla queda en blanco | Un error de ASP que no se está mostrando | Activar temporalmente los mensajes de error detallados en esa página |
| GlobalLink responde «credencial no encontrada» | Buscamos en las tres bases y no está: la página escribió en otra base, o la credencial ya venció | Decirnos en qué base escribió y la ampliamos a esa |
| GlobalLink responde «credencial ya usada» | Se reenvió la misma; es el comportamiento correcto | Volver a entrar desde la pestaña genera una nueva |
Preguntas que suelen aparecer
| ¿Viaja alguna contraseña? | Ninguna. Ni la de TNI ni la de GlobalLink. La credencial es un número aleatorio sin significado. |
| ¿Y si alguien intercepta la credencial? | Tiene dos minutos y un solo uso. Además viaja dentro del cuerpo del envío, cifrado por HTTPS, y nunca aparece en la barra de direcciones ni en los registros del servidor. |
| ¿Pueden entrar a GlobalLink sin pasar por nuestro sistema? | No con este método. La credencial sólo la genera su página, para alguien que ya tiene sesión en el sistema de gestión. |
| ¿Qué pasa si damos de baja a un empleado? | La consulta del paso 4 filtra por FechaBaja IS NULL, así que su página deja de emitir credenciales. Además nosotros leemos esa fecha cada noche y desactivamos la cuenta. |
| ¿Necesitan acceso a nuestro sistema? | No. Su página sólo escribe en su propia base y envía un formulario. |
| ¿Hay que abrir algún puerto? | No. Es el navegador del empleado el que envía el formulario, no el servidor de TNI. |
| ¿Y si el empleado usa dos oficinas? | Entra con la que tenga en dbo.Empleados. Si en el futuro hacen falta varias, se resuelve del lado de GlobalLink sin tocar su página. |
| ¿Se puede probar en un ambiente de pruebas de ustedes? | Sí. Si tienen una copia del portal, la página funciona igual apuntando a nuestra dirección de pruebas. |
Lista de verificación de R7
| Paso | Qué queda hecho | Cómo se comprueba |
|---|---|---|
| 1 | Tabla e índice creados en las tres bases | La consulta de columnas devuelve cinco filas por base |
| 2 | Permiso de inserción y borrado al usuario del portal | El INSERT de prueba devuelve una credencial |
| 3 | Anotados el nombre de la variable de sesión y la cadena de conexión | Escritos en el ticket, para el paso 4 |
| 4 | Página creada con las dos líneas ajustadas | Se abre sin error con una sesión iniciada |
| 5 | Prueba local: aparece la credencial y la fila en la tabla | La consulta ordenada por fecha muestra la fila |
| 6 | Prueba contra nuestra dirección de pruebas | Las nueve líneas del informe en «sí» |
| 7 | Pestaña en el menú, apuntando a la página | Un empleado entra a GlobalLink desde su sistema |
Criterio de aceptación de R7. Un empleado real de Guatemala hace clic en la pestaña y llega a GlobalLink con su nombre, su oficina y su rol, sin escribir contraseña. Y un segundo envío de la misma credencial es rechazado.
Qué no hacer. No poner la credencial en la dirección. No alargar la caducidad más allá de cinco minutos. No dejar la credencial visible en pantalla en la versión definitiva. No guardar la contraseña del empleado en ningún campo. No reutilizar una credencial ya enviada.
Si prefieren un token firmado en lugar de la credencial en tabla
Las dos variantes conservan todo lo demás igual: la misma pestaña, el mismo envío por formulario, los mismos identificadores crudos, la misma dirección de destino. Cambia sólo qué se manda en el campo credencial.
Variante con secreto compartido. Requiere ASP.NET. El secreto se intercambia una vez por la bóveda y se guarda cifrado en web.config. Nos ahorra la consulta a su base, a cambio de manejar un secreto:
using System;
using System.Security.Cryptography;
using System.Text;
static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
string CrearPase(int idEmpleado, int idPais, int idOficina, int idCargo,
string nombre, string email, string secreto)
{
long ahora = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string cabecera = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
string cuerpo =
"{\"iss\":\"https://www.trulynoleninternational.com\"," +
"\"aud\":\"tn-globallink\",\"sub\":\"" + idEmpleado + "\"," +
"\"name\":\"" + nombre.Replace("\"", "") + "\"," +
"\"email\":\"" + (email ?? "") + "\"," +
"\"tni_id_pais\":" + idPais + ",\"tni_id_oficina\":" + idOficina +
",\"tni_id_cargo\":" + idCargo + "," +
"\"iat\":" + ahora + ",\"nbf\":" + ahora + ",\"exp\":" + (ahora + 300) + "," +
"\"jti\":\"" + Guid.NewGuid() + "\"}";
string sinFirma = B64Url(Encoding.UTF8.GetBytes(cabecera)) + "." +
B64Url(Encoding.UTF8.GetBytes(cuerpo));
using (var h = new HMACSHA256(Encoding.UTF8.GetBytes(secreto)))
return sinFirma + "." + B64Url(h.ComputeHash(Encoding.UTF8.GetBytes(sinFirma)));
}
Variante con par de llaves. Es la especificación acordada en mayo, con la llave pública publicada en una dirección fija en formato JWKS. Si algún día se prefiriera este camino, el contenido del token y la rotación de llaves están descritos acá y las comprobaciones 6 a 11 de la sección 8.4 valen igual; cambian sólo las cinco primeras. No es lo que se va a construir. Ver la sección 8.
using Microsoft.IdentityModel.Tokens; // NuGet: System.IdentityModel.Tokens.Jwt using System.IdentityModel.Tokens.Jwt; var rsa = new RSACryptoServiceProvider(); rsa.ImportParameters(llavePrivada); // del almacén de certificados del servidor var cred = new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = "tni-2026-09" }, SecurityAlgorithms.RsaSha256); var token = new JwtSecurityToken( issuer: "https://www.trulynoleninternational.com", audience: "tn-globallink", claims: new[] { new Claim("sub", idEmpleado.ToString()), new Claim("name", nombre), new Claim("email", email ?? ""), new Claim("tni_id_pais", idPais.ToString()), new Claim("tni_id_oficina", idOficina.ToString()), new Claim("tni_id_cargo", idCargo.ToString()), new Claim("jti", Guid.NewGuid().ToString()) }, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes(5), signingCredentials: cred); string pase = new JwtSecurityTokenHandler().WriteToken(token);
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 la vamos a cargar 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 los cuatro GRANT | 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 pase: reconoce quién inició sesión, genera una credencial de un solo uso, la guarda, y arma la URL hacia GlobalLink con esa credencial | Equipo del portal | 2 a 3 horas · no bloquea |
| Desarrollo | R7 · La tabla dbo.GlobalLinkAccesos, donde vive el pase mientras se usa, y sus permisos. Desde el 4-sep lleva además Correo e IdIdioma | Equipo del portal + DBA | Incluido en las 2 a 3 horas |
| 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 | Aviso de usuario creado; contraseña por la bóveda | Consulta de permisos y prueba negativa desde nuestro servidor |
| 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 | Tabla creada y página publicada | Las nueve líneas del informe de pruebas 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: valida la forma del campo credencial sin tocar la base; busca la fila en dbo.GlobalLinkAccesos de las tres bases productivas con el usuario de lectura user_globallink que ya existe, uniendo contra dbo.Empleados para traer nombre, correo, IdPais, IdOficina e IdCargo en la misma consulta; comprueba la vigencia con la hora del propio SQL Server de TNI y un tope propio de cinco minutos; marca el canje insertando en una tabla nueva app.sso_pases_vistos (credencial uuid PRIMARY KEY, visto_en), cuya llave única es la que garantiza el uso único —del lado nuestro, sin pedirle a TNI ningún permiso de escritura—; traduce el país y la oficina contra silver.oficinas y el cargo contra una tabla nueva app.tni_cargo_rol (id_cargo, role) que llenamos nosotros con la equivalencia de R8; busca o crea la cuenta en public.users por una columna nueva id_empleado_tni, que es la única llave de resolución (external_sub se escribe como valor derivado tni: + número, y no se usa para buscar); abre sesión con signSession y setSessionCookie de lib/auth/session.ts, registra en app.user_sessions y deja el intento —con su resultado y su motivo— en audit.events.
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 elegido en R7: la credencial de un solo uso. Dice qué pieza es de cada lado, qué viaja y qué no, las once comprobaciones en orden, y quién sostiene cada garantía de seguridad. La variante con token firmado, que fue la especificación acordada entre los equipos técnicos el 5 de mayo de 2026, queda como anexo al final de R7: no se descarta, pero no es lo que se construye.
Truly Nolen GlobalLink ↗Cabecera y pestañas copiadas de la pantalla real del 3 de septiembre; la pestaña nueva es la propuesta.
8.1 · Las partes, y de quién es cada una
| Pieza | Qué es y dónde vive | De quién es · quién la construye y la mantiene |
|---|---|---|
| dbo.GlobalLinkAccesos | La tabla donde se guarda cada credencial. En las tres bases productivas de TNI. | TNI La crea su administrador de base de datos (R7, paso 1). No la tocamos: sólo la leemos. |
| globallink.aspo .aspx | La página que lee la sesión del portal, guarda la credencial y la envía. En /proposals/TG/; el nombre y la extensión los eligen ellos. | TNI La escribe su equipo del portal con el código de R7. Es el único desarrollo nuevo del lado de TNI. |
| La pestaña del menú | Una entrada más en la barra que ya tiene Home y Reports, apuntando a esa página. | TNI R6. Nosotros entregamos el logo y el fragmento de HTML. |
| user_globallink | El usuario de lectura con el que ya leemos su base cada noche. | TNI Ya existe y ya tiene alcance a nivel de base: la tabla nueva queda cubierta sin ningún permiso adicional. |
| POST /globalink/auth/exchange | El punto de entrada que recibe la credencial y abre la sesión. En app.trulynolen.tech. | Catalizadora Lo construimos, lo operamos y respondemos por él. Público, sin sesión previa, declarado en apps/web/proxy.ts. |
| app.sso_pases_vistos | Donde anotamos cada credencial ya canjeada, para que no sirva dos veces. | Catalizadora Vive en nuestra base, no en la de TNI. Por eso el un-solo-uso no les cuesta ningún permiso de escritura. |
| app.tni_cargo_rol | La equivalencia entre cada cargo de TNI y el rol dentro de GlobalLink. | Catalizadora La cargamos nosotros con la equivalencia informada en R8; TNI puede objetar cualquier fila. Su página no traduce nada. |
| Destino y página de error | /globalink/ y /globalink/auth/exchange/error?motivo=…, siempre con enlace de vuelta a su sistema. | Catalizadora |
8.2 · La credencial: qué se guarda y qué viaja
La credencial es un identificador único de SQL Server (UNIQUEIDENTIFIER, 122 bits aleatorios) generado por el propio motor con NEWID(). No lleva datos dentro, no está firmada y no significa nada por sí sola: es una llave de guardarropa. Todo lo que importa está en la fila que la acompaña, dentro de la base de TNI.
| Columna | Ejemplo | Quién la escribe | Regla que hacemos cumplir al recibirla |
|---|---|---|---|
| Token | 3f2504e0-4f89-41d3-9a0c-0305e82c3301 | motor SQL | Es lo único que viaja. Tiene que tener forma de identificador único; cualquier otra cosa se rechaza sin consultar la base. |
| IdEmpleado | 22059 | su página | La identidad estable. Si cambia el correo o el nombre, la cuenta sigue siendo la misma. Se resuelve contra dbo.Empleados. |
| Creado | 2026-09-03 21:14:07 | motor SQL | En UTC, con SYSUTCDATETIME(). Sirve para auditar y para la limpieza. |
| Vence | 2026-09-03 21:16:07 | su página | Dos minutos después de crearse. Comparamos contra la hora del propio SQL Server de TNI, no contra la nuestra: así no hay problema de relojes desfasados. |
| Origen | 190.56.12.4 | su página | Opcional. No decide nada; queda para auditar. |
El resto de la identidad —país, oficina y cargo— no viaja en la credencial: lo leemos de dbo.Empleados en la misma consulta, crudo, con los identificadores de TNI. Eso es deliberado: nadie puede alterar por el camino la oficina o el cargo con que entra, porque nunca salen de su base.
8.3 · Cómo viaja
La página de TNI devuelve un documento HTML mínimo con un formulario que se envía solo. La credencial va en el cuerpo, no en la dirección: así no queda en el historial del navegador, ni en la cabecera Referer, ni en los registros de ningún servidor intermedio.
<!doctype html><meta charset="utf-8"><title>Entrando a GlobalLink…</title>
<form id="f" method="post" action="https://app.trulynolen.tech/globalink/auth/exchange">
<input type="hidden" name="credencial" value="3f2504e0-4f89-41d3-9a0c-0305e82c3301">
<noscript><button>Entrar a GlobalLink</button></noscript>
</form>
<script>document.getElementById("f").submit();</script>
Cabeceras de esa respuesta: Cache-Control: no-store y Referrer-Policy: no-referrer. La credencial nunca se escribe en un log del lado de TNI. Nada sale del servidor de TNI hacia el nuestro: es el navegador del empleado el que hace el envío, así que no hay que abrir ningún puerto ni permitir ninguna dirección.
8.4 · Qué hace GlobalLink al recibirla, en orden
Las once comprobaciones. Las cinco primeras son propias de este método; 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.
| # | Comprobación | Si falla | Motivo registrado |
|---|---|---|---|
| 1 | El cuerpo trae credencial con forma de identificador único. Si no, ni siquiera se consulta la base de TNI. | 400 | credencial_malformada |
| 2 | Existe la fila en dbo.GlobalLinkAccesos. Se busca en las tres bases productivas, con el usuario lector. | 401 | credencial_no_encontrada |
| 3 | Vence > SYSUTCDATETIME(), evaluado por el propio SQL Server de TNI en la misma consulta. | 401 | credencial_vencida |
| 4 | La credencial no fue canjeada antes. Se inserta en app.sso_pases_vistos dentro de la misma transacción; si la inserción choca con la llave única, es un reenvío o un intento de repetición. | 401 | credencial_ya_usada |
| 5 | La antigüedad de la fila es menor a 5 minutos aunque su Vence diga otra cosa. Es un tope nuestro, por si alguna vez su página se despliega con una caducidad más larga. | 401 | credencial_demasiado_vieja |
| 6 | El empleado existe en dbo.Empleados, con FechaBaja IS NULL. | 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 asignado en app.tni_cargo_rol, la tabla que carga la equivalencia de R8. | 403 | cargo_sin_equivalencia · cargo_sin_acceso |
| 10 | Cuenta: se busca en public.users por id_empleado_tni. Si no existe, se crea con external_sub = 'tni:' + IdEmpleado, correo, nombre, rol, oficina y país leídos de su base. Si existe, se actualizan esos campos; si estaba desactivada, se rechaza. | 403 | cuenta_desactivada |
| 11 | Sesión: cookie tn_session firmada por GlobalLink, HttpOnly, Secure en producción, SameSite=Lax, Path=/, vida de 8 horas que se renueva con actividad, ligada a una fila en app.user_sessions con dispositivo, IP y ubicación aproximada. Es la misma sesión que abre el login normal. Respuesta: 303 See Other al tablero de su oficina. | — | — |
Cada intento, exitoso o no, escribe una fila en audit.events con action = 'auth.exchange', la IP y el agente de usuario en sus columnas, y dentro de details el resultado, el motivo y el número de empleado. La credencial misma nunca se registra. Límite: 30 intentos por minuto por IP; por encima, 429 sin detalle.
8.5 · Quién garantiza cada propiedad de seguridad
Esta es la lectura que va a querer un auditor: para cada garantía, qué lado la sostiene y con qué mecanismo. Ninguna depende de que el otro lado se acuerde de algo.
| Garantía | Cómo se sostiene del lado de TNI | Cómo se sostiene del lado de Catalizadora |
|---|---|---|
| Sólo entra quien ya tiene sesión en TNI | La página empieza comprobando la variable de sesión del portal; sin ella redirige al login y no genera nada. | No existe otra manera de obtener una credencial válida: no la emitimos nosotros. |
| La credencial dura poco | Su página escribe Vence a dos minutos. | Además rechazamos cualquier fila de más de cinco minutos, aunque su Vence diga otra cosa (comprobación 5). |
| Sirve una sola vez | Nada. No les cuesta permisos ni desarrollo. | La anotamos en nuestra base al canjearla, con llave única. El segundo intento choca y se rechaza. |
| Nadie altera su oficina ni su cargo | No hace falta que los manden: viven en dbo.Empleados. | Los leemos de su base en la misma consulta. Lo que viaja no contiene identidad. |
| Un empleado dado de baja no entra | La consulta de su página filtra por FechaBaja IS NULL y deja de emitirle credenciales. | Lo volvemos a comprobar al canjear, y cada noche el espejo desactiva la cuenta y revoca sus sesiones abiertas. |
| Quién puede hacer qué dentro de GlobalLink | Pueden objetar cualquier fila de la equivalencia de R8. Su página no decide permisos. | Traducimos el cargo con esa tabla; un cargo sin fila no entra. |
| La tabla no crece sin control | Su página borra las filas vencidas de más de un día antes de insertar (paso 4 de R7). Sin trabajo programado ni mantenimiento: con unos 4.000 accesos diarios la tabla se estabiliza en ese orden de filas. | — |
| Queda registro de todo intento | Opcionalmente, Origen y Creado en su propia tabla. | Una fila por intento en audit.events, consultable por el rol auditor. |
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: automática en el primer acceso, con los datos leídos de su tabla de empleados. No hay invitación ni contraseña de GlobalLink.
- Cambio: nombre, correo, oficina y rol se releen de dbo.Empleados en cada acceso. TNI manda.
- Baja: cuando TNI da de baja al empleado, su página deja de emitirle credenciales y el acceso se corta solo; además, cada noche el espejo trae Empleados.FechaBaja y GlobalLink desactiva la cuenta y revoca sus sesiones abiertas. Ese motivo de revocación no existe hoy en app.user_sessions (admite cierre de sesión, revocación remota, revocación por administrador, vencimiento y fusión de oficinas): la migración del acceso agrega employee_offboarded.
- Convivencia con el login normal: una cuenta creada por este camino puede recibir después una contraseña de GlobalLink si TNI lo pide; una cuenta creada a mano queda ligada al empleado la primera vez que entra por la pestaña con el mismo correo.
8.8 · Qué entrega cada lado, y cómo se prueba
TNI entrega · tres cosas, cuando puedan
- La tabla dbo.GlobalLinkAccesos creada en sus bases productivas, con el script de R7 paso 1 ejecutado tal cual.
- La página globallink.asp —o .aspx, según la tecnología que elijan— publicada con el código de R7 paso 4, y la pestaña de R6 apuntando a ella.
- Nada más. La equivalencia de cargos (R8) la cargamos nosotros, con los 10 cargos con empleados activos ya resueltos, ocho con rol y dos sin acceso; si alguna fila no corresponde, se corrige con un correo y sin desplegar nada.
Nada más. Para esto no hay que abrir puertos, ni crear usuarios de base de datos, ni instalar librerías, ni administrar llaves, ni programar tareas de mantenimiento.
Catalizadora entrega · todo lo demás
- El punto de entrada con las once comprobaciones, en producción y en una dirección de pruebas separada que habilitamos para su equipo antes de que toquen producción.
- El informe de pruebas en texto claro (R7 paso 6): nueve líneas que dicen qué comprobación pasó y cuál falló, para que su equipo depure sin depender de nosotros.
- El logo en PNG y SVG, y el fragmento de HTML de la pestaña.
- La tabla de equivalencia cargada, el registro de auditoría consultable por el rol auditor, y esta especificación versionada en specs/ACCESO-SSO.md versión 2.
- Un contacto técnico disponible durante la prueba conjunta, que dura menos de una hora.
Las ocho pruebas de aceptación. P1 la corremos juntos; P2 a P5 las corre su equipo solo, porque el resultado se ve en el informe de texto claro; P6 a P8 las corremos nosotros contra la dirección de pruebas.
| # | Prueba | Resultado esperado | Quién la corre |
|---|---|---|---|
| P1 | Un vendedor de Guatemala entra desde la pestaña | Llega al tablero de la oficina 679 con rol de vendedor; fila en auditoría con resultado ok | Los dos, juntos |
| P2 | Se reenvía la misma credencial una segunda vez | 401 credencial_ya_usada. Volver a la pestaña genera una nueva y entra. | TNI, solo |
| P3 | Se espera tres minutos antes de enviar | 401 credencial_vencida | TNI, solo |
| P4 | Se envía un identificador único inventado | 401 credencial_no_encontrada, sin filtrarse ninguna información sobre qué existe en su base | TNI, solo |
| P5 | Se abre la página sin sesión en el sistema de gestión | Redirige a su login. No se crea ninguna fila en la tabla. | TNI, solo |
| P6 | Entra un empleado cuyo cargo no está en la equivalencia | 403 cargo_sin_equivalencia, con la página de error en su idioma y el enlace de vuelta al sistema de gestión | Catalizadora |
| P7 | Entra un empleado que nunca usó GlobalLink | Se crea la cuenta sola y entra; public.users.id_empleado_tni queda escrito | Catalizadora |
| P8 | Entra un empleado con fecha de baja | 403 empleado_inactivo; sus sesiones anteriores quedan revocadas | Catalizadora |
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 la columna IdIdioma de dbo.GlobalLinkAccesos, junto al pase. No va en la dirección web, igual que el identificador y el correo: la dirección lleva sólo la credencial, y los datos de la persona se leen de la fila. Es deliberado — poner el correo de un empleado en una dirección web lo deja escrito 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
Pedido de TNI del 4 de septiembre: una dirección temporal contra la cual autenticar, para poder seguir probando sin esperar a que todo lo demás esté listo. Nos parece bien y la entregamos nosotros.
Qué devuelve. Una página simple con lo que leyó: el número de empleado, el correo, el número de idioma que mandaron y a qué idioma nuestro lo tradujimos, la oficina y el país que resolvimos, y el rol que le tocaría. Si algo falla, dice cuál de las once comprobaciones falló y por qué, con el mismo texto que usará la definitiva.
Qué no hace. No crea sesión, no deja entrar a GlobalLink, no escribe en ninguna base —ni en la de ustedes ni en la nuestra—, y no consume la credencial: pueden repetir la misma prueba las veces que quieran. Vive en nuestro entorno de pruebas, no en producción, y se apaga el día que la definitiva entre.
Ya está viva. Ésta es la dirección:
https://app.trulynolen.tech/globalink/pase-prueba?credencial=<el Token que insertó su página>
Construida y publicada el 4 de septiembre de 2026. Corre en el mismo servidor que ya lee su base —el que está en su lista blanca— y usa el usuario lector que ustedes ya nos dieron: no hace falta la firma, ni el usuario de escritura, ni ninguna regla de firewall nueva. Pueden apuntarle el botón hoy mismo.
Qué contesta hoy, antes de que creen la tabla. Si le mandan una credencial ahora, responde que dbo.GlobalLinkAccesos todavía no existe en ninguna de las tres bases y les muestra en pantalla la sentencia exacta para crearla. En cuanto la creen, esa misma dirección empieza a identificar personas sin que haya que tocar nada de este lado. Sin credencial explica qué parámetro espera; con una credencial mal formada lo dice.
Para 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 |