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.
Las de Catalizadora se construyen y se prueban en modo sombra sin escribir en TNI. Las de TNI son accesos, una columna y tres confirmaciones. Las de TNI están escritas en detalle 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 · necesita las confirmaciones 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 | idempotency_key · requerimiento R4 | TNI | Bloque 2 |
| T5 | Tres confirmaciones: estado 5 y tipo 1 para el prospecto; qué ponen en Identidad cuando no la tienen; «GlobalLink» como referencia nueva o «Otro» | AuxStatusClientes · AuxTiposClientes · AuxTiposReferencias | TNI | Bloque 2 |
| 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 el pase, verifica y abre sesión; alta automática por número de empleado | app/(auth)/exchange/route.ts · proxy.ts · public.users.id_empleado_tni · 2 tablas nuevas | Catalizadora | Paralelo |
| T6 | La pestaña «Truly Nolen GlobalLink» en su sistema, con el logo y el fragmento que entregamos | plantilla del menú del sistema de gestión | TNI | Paralelo |
| T7 | La página que emite el pase firmado, y la llave pública publicada | 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 | app.tni_cargo_rol, la llenan ellos | TNI | Paralelo |
Están escritos para que su equipo técnico los ejecute sin preguntarnos nada, y para que un tercero pueda auditar que se cumplieron. Los valores que faltan (contraseña, dirección de nuestro servidor) viajan por canal privado, nunca por este documento.
Qué: la firma de José Lutz sobre el PDF ya redactado, que autoriza altas en cuatro tablas de las tres bases productivas.
Por qué: el Term Sheet, cláusula 1, fija modalidad de sólo lectura; sin el addendum, escribir es incumplimiento.
Exactamente cómo: nos devuelven el PDF firmado; nosotros lo depositamos en el servidor de la plataforma como /etc/tn-globallink/writeback-addendum-firmado. El programa de escritura comprueba la existencia y el tamaño de ese archivo antes de cada corrida; sin archivo, no emite ninguna alta.
Cómo lo verificamos: el registro de cada corrida deja addendum_file_exists=true; el primer informe mensual lo cita.
Cuándo: bloque 4, antes de la primera escritura real.
Qué: un login de SQL Server distinto del lector actual, con permiso de alta y lectura sobre exactamente cuatro tablas, en Truly, TrulyNew y TrulyRuso. Ningún permiso sobre TrulyGestion.
Por qué: separar lectura de escritura permite revocar una sin tocar la otra, y acota el daño posible de cualquier incidente a altas en cuatro tablas. El SELECT sobre esas mismas cuatro tablas hace falta para que el alta devuelva el número asignado (OUTPUT INSERTED).
Exactamente cómo:
-- una vez, en el servidor CREATE LOGIN user_globallink_writer WITH PASSWORD = '…', CHECK_POLICY = ON, CHECK_EXPIRATION = OFF; -- en cada una de las tres bases productivas CREATE USER user_globallink_writer FOR LOGIN user_globallink_writer; 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; 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; -- nada más: sin EXECUTE, sin acceso a dbo.Usuarios ni dbo.Empleados, sin db_datawriter
La contraseña se entrega por la bóveda de secretos de Catalizadora, no por correo ni chat; nosotros la guardamos en /etc/tn-globallink/sqlserver-writer.env con permisos 600.
Cómo lo verificamos: desde nuestro servidor, con ese usuario, una consulta fn_my_permissions sobre las cuatro tablas tiene que devolver INSERT y SELECT y nada más, y un intento de UPDATE tiene que fallar con error 229. Ese resultado queda en el registro de la primera corrida.
Cuándo: bloque 4, dentro de los 10 días hábiles siguientes a la firma, como fija la cláusula 4 del addendum.
Qué: permitir conexiones al puerto del SQL Server desde una única dirección IP, la del servidor de la plataforma, para ese login. La dirección se entrega por canal privado; es la misma desde la que hoy se conecta el usuario lector.
Exactamente cómo: regla de firewall que admita esa IP/32 al puerto de la instancia; si el login se restringe por origen, la misma IP.
Cómo lo verificamos: conexión de prueba desde nuestro servidor con el usuario de escritura, en modo sombra (sin insertar), registrada en el log de la corrida.
Cuándo: bloque 4, junto con R2.
Qué: una columna nueva, nula por omisión, con índice único filtrado, en dbo.Clientes, dbo.Contactos, dbo.Servicios y dbo.Propuestas, en las tres bases.
Por qué: si nuestro programa reintenta un alta que sí llegó (corte de red después del INSERT), el motor rechaza la segunda con el índice único y no se duplica el cliente. Es la garantía de motor; sin ella, la única defensa es nuestra búsqueda previa.
Exactamente cómo:
-- repetir para Contactos, Servicios y Propuestas; en Truly, TrulyNew y TrulyRuso
ALTER TABLE dbo.Clientes ADD idempotency_key UNIQUEIDENTIFIER NULL;
CREATE UNIQUE NONCLUSTERED INDEX UX_Clientes_idempotency_key
ON dbo.Clientes (idempotency_key) WHERE idempotency_key IS NOT NULL;
Impacto: ninguno sobre datos ni pantallas existentes: las filas actuales quedan en nulo y el índice filtrado no las incluye. Su aplicación no necesita conocer la columna. Nota sobre el texto del addendum: la cláusula 4 la describe como NOT NULL con UNIQUE; esa forma rompería las filas existentes, que no tienen clave. La forma de acá, nula con índice filtrado, cumple el mismo fin sin tocar el histórico; conviene dejarlo escrito en la firma.
Cómo lo verificamos: la columna aparece en el catálogo que leemos cada noche, y el primer reintento controlado en modo real devuelve el error 2601 esperado.
Cuándo: bloque 2. Si no está cuando se encienda la escritura, el programa usa la marca en observaciones como única defensa, y lo dice en el informe.
Qué: tres respuestas cortas de quien conoce su sistema, sobre datos que leímos de su base y necesitamos escribir igual que ustedes.
5a · Estado y tipo del prospecto. Leímos que el prospecto es IdStatusCliente = 5 («Potential»), y que Guatemala tiene 1.268 clientes así. Para IdTipoCliente proponemos 1 («Creative», venta activa) porque el prospecto sale de prospección; su oficina de Guatemala registra el 72 % de sus potenciales como 2 («Office»). ¿Confirman 5 y qué tipo quieren para los prospectos que vienen de GlobalLink?
5b · Identidad. Su sistema llena Clientes.Identidad en el 100 % de los prospectos. Nosotros la tenemos sólo cuando el registro oficial del país la trae. ¿Qué escriben cuando no la conocen, o aceptan nulo?
5c · Referencia. Para que el prospecto diga de dónde vino, proponemos IdTipoReferencia = 6 («Other») con ReferenciaOtra = 'TN GlobalLink · Rocket' o '· Manual'. Mejor aún sería una fila nueva «GlobalLink» en AuxTiposReferencias, que ustedes agregan y nosotros leemos del espejo. ¿Cuál prefieren?
Cómo lo verificamos: los tres valores quedan como constantes del mapeo, con su origen citado, y aparecen en el primer alta de prueba.
Cuándo: bloque 2, antes de probar el alta en sombra.
Qué: una pestaña más en la barra de menú, a la derecha de «Reports», visible para los perfiles que ustedes decidan. Nosotros entregamos el logo en PNG y SVG y el fragmento HTML.
Exactamente cómo: la pestaña apunta a la página de R7, dentro de su propio sitio; no a GlobalLink. Fragmento de referencia, adaptable a su plantilla:
<a class="tab" href="globallink.aspx" target="_blank" rel="noopener"> <img src="img/tn-globallink.png" alt="" width="16" height="16"> Truly Nolen GlobalLink ↗ </a>
Cómo lo verificamos: con el usuario de prueba que ya tenemos, la pestaña aparece y abre la página de R7.
Cuándo: en paralelo con el resto.
Qué: una página nueva en su sitio (por ejemplo /proposals/TG/globallink.aspx) que, para el usuario con sesión, arme un pase firmado y lo envíe a GlobalLink; y la llave pública publicada en una dirección fija.
Por qué: es lo que permite entrar a GlobalLink sin segunda contraseña y sin que ninguna contraseña viaje. Su llave privada nunca sale de su servidor; nosotros sólo verificamos.
Exactamente cómo: la especificación completa está en la sección 8 de esta página: contenido del pase, algoritmo, vigencia, formato de la llave pública, formulario de envío y errores. En resumen: JWT firmado con RS256 (llave RSA de 2048 bits o más), claims iss, aud, sub, iat, exp, nbf, jti, email, name, country, role, franchise_id, language, vigencia de 300 segundos, y envío por POST en un formulario que se envía solo. La llave pública, en formato JWKS, en https://www.trulynoleninternational.com/.well-known/jwks.json o en la dirección fija que ustedes definan.
Datos que la página lee de dbo.Empleados: IdEmpleado (va en sub), Nombre y Apellido (en name), Email, IdPais (convertido a ISO de dos letras en country), IdOficina (en franchise_id) e IdCargo (convertido con la tabla de R8 en role).
Si no pueden publicar una llave pública en su plataforma, la variante con secreto compartido (HS256) usa exactamente el mismo pase; cambia sólo la firma, y el secreto se intercambia por la bóveda. Lo decide su equipo.
Cómo lo verificamos: un pase de prueba emitido por su página, verificado por nuestro punto de entrada en la dirección de pruebas, con las once comprobaciones de la sección 8.5 en verde y el registro de auditoría escrito.
Cuándo: en paralelo. Del lado nuestro el punto de entrada se prueba antes con un emisor de laboratorio; el día que su llave esté publicada sólo cambia una dirección de configuración.
Qué: una tabla de dos columnas: cada IdCargo de dbo.Empleados que vaya a entrar, y el rol de GlobalLink que le corresponde.
Los siete roles de GlobalLink y qué pueden hacer: tni_admin ve toda la red · country_admin ve su país y reparte trabajo entre oficinas · auditor sólo lectura de toda la red · franchise_owner, franchise_admin y franchise_manager ven su oficina y reparten trabajo · franchise_user es el vendedor: ve y trabaja sus negocios. La especificación de mayo nombraba un rol tni_regional que no existe; su equivalente es country_admin.
Exactamente cómo: nos mandan la tabla (hoja de cálculo o correo); nosotros la cargamos en app.tni_cargo_rol. Un cargo sin fila en esa tabla no entra: el pase se rechaza con un mensaje claro, no se adivina un rol.
Cómo lo verificamos: un pase de prueba por cada cargo de la tabla abre GlobalLink con el rol esperado.
Cuándo: en paralelo, antes de la primera prueba de acceso.
Las de TNI están en la sección 2. Éstas son las nuestras, en el orden de la tabla.
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.
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».
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.
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».
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.
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.
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ó».
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.
Ruta nueva apps/web/app/(auth)/exchange/route.ts, POST, declarada pública en apps/web/proxy.ts. Verifica con la biblioteca jose que la aplicación ya usa: llave pública remota con caché de una hora, emisor y destinatario fijos, tolerancia de 30 segundos, vida máxima de 5 minutos. El número único se guarda en una tabla nueva app.sso_pases_vistos (jti, visto_en) con limpieza a los 10 minutos. Busca o crea en public.users por external_sub, y llena una columna nueva public.users.id_empleado_tni con el número de empleado del pase. El rol sale de una tabla nueva app.tni_cargo_rol (id_cargo, role) que llena TNI. Abre sesión con signSession y setSessionCookie de lib/auth/session.ts, registra en app.user_sessions y deja el intento en audit.events.
Configuración, por la bóveda: TNI_IDP_ISSUER y TNI_IDP_JWKS_URL. Prueba de punta a punta contra infra/mock-idp, que ya emite pases con este formato. Entrega a TNI: el logo, el fragmento HTML de la pestaña y del formulario de envío, y specs/JWT-SPEC.md actualizada con la dirección de hoy.
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.
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 | — |
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. TNI confirma | IdTipoClienteint · obligatoria | arreglar hoy no se manda confirmar |
| 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 (el pase trae el IdEmpleado); mientras tanto, cruce 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 | Provincianvarchar(50) | va |
| 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 | Webnvarchar(150) | va |
| app.companias.industriatexto | Tal cual | Rubronvarchar(max) · TNI lo llena en el 3 % | va |
| enriquecimiento del padrónNIT, RFC, RUT, CUIT | Sólo si el registro oficial lo trajo. TNI la llena en el 100 % de sus prospectos: hay que preguntarles qué ponen cuando no la tienen | Identidadnvarchar(50) | confirmar |
| 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 | 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 | 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.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á |
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.
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.
Su sistema arma un pase con su número de empleado, su nombre, su país, su oficina y su cargo, lo firma, y lo manda a GlobalLink. Dura cinco minutos y sirve una vez.
Truly Nolen GlobalLink ↗Verifica la firma y abre su tablero de Guatemala, oficina 679, con permisos de vendedor. Como el pase trae su número de empleado, GlobalLink ya sabe quién es Carlos en TNI: no hace falta cruzar nada a mano.
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.
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.
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 que TNI confirme (proponemos «venta activa»). TNI devuelve el número de cliente y queda guardado en el negocio.
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 10 | MORALES, C. | Guatemala | Potencial |
| 587997 | GUATE | — | CLINICA SANTA LUCIA Av. Reforma 12-01 zona 9 | RAMIREZ, A. | Guatemala | Potencial |
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.
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 |
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.
Desde GlobalLink, con el monto. En TNI: otro renglón «Lead» en la bitácora con «propuesta enviada · USD 1.800 anuales».
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 |
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.
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.
Base: la especificación acordada entre los equipos técnicos el 5 de mayo de 2026, actualizada con la dirección real de la plataforma, los roles reales y dos endurecimientos: el pase viaja por POST y el rol se resuelve por una tabla de equivalencias. Lo que sigue es el contrato completo.
Truly Nolen GlobalLink ↗Cabecera y pestañas copiadas de la pantalla real del 3 de septiembre; la pestaña nueva es la propuesta.
| Elemento | Valor | Quién lo pone |
|---|---|---|
| Emisor (iss) | https://www.trulynoleninternational.com propuesto; TNI confirma | TNI · el valor que confirmen se configura en GlobalLink y debe coincidir letra por letra en cada pase |
| Llave pública (JWKS) | https://www.trulynoleninternational.com/.well-known/jwks.json propuesto; TNI confirma | TNI · o la dirección fija que definan; se configura una vez en GlobalLink |
| Página emisora | /proposals/TG/globallink.aspx nombre a elección de TNI | TNI · requiere sesión del sistema de gestión |
| Destinatario (aud) | tn-globallink | Fijo |
| Punto de entrada | POST https://app.trulynolen.tech/globalink/auth/exchange | Catalizadora · público, sin sesión previa, declarado en apps/web/proxy.ts |
| Destino tras entrar | https://app.trulynolen.tech/globalink/ el tablero de la oficina del usuario | Catalizadora |
| Página de error | https://app.trulynolen.tech/globalink/auth/exchange/error?motivo=… | Catalizadora · con enlace de vuelta al sistema de gestión |
Un JWT (RFC 7519) firmado con RS256 (RSA con SHA-256, llave de 2048 bits como mínimo). Cabecera y contenido:
// cabecera { "alg": "RS256", "typ": "JWT", "kid": "tni-2026-09" } // contenido · ejemplo de un vendedor de Guatemala { "iss": "https://www.trulynoleninternational.com", "aud": "tn-globallink", "sub": "22059", // dbo.Empleados.IdEmpleado, como texto "iat": 1788500000, // emitido, segundos Unix UTC "nbf": 1788500000, // no antes de · igual a iat "exp": 1788500300, // vence · iat + 300 como máximo "jti": "5f1c9b2e-7a3d-4e0b-9c61-2f8d4a7b3c10", // único por pase, UUID v4 "email": "carlos.morales@trulynolen.com.gt", // dbo.Empleados.Email "name": "Carlos Morales", // Nombre + Apellido "country": "gt", // ISO 3166-1 alfa-2 en minúsculas, desde IdPais "franchise_id": "679", // dbo.Empleados.IdOficina, como texto "role": "franchise_user", // resuelto con la tabla de cargos (R8) "language": "es" // ISO 639-1 · opcional }
| Campo | Tipo | Obligatorio | Regla |
|---|---|---|---|
| iss | texto | sí | Igual al emisor configurado. Cualquier otro valor: rechazo. |
| aud | texto | sí | Exactamente tn-globallink. |
| sub | texto | sí | El IdEmpleado de TNI. Es la identidad estable: si cambia el correo o el nombre, la cuenta sigue siendo la misma. |
| iat · nbf · exp | entero | sí · no · sí | Segundos Unix en UTC. exp − iat ≤ 300. Tolerancia de reloj: 30 segundos en cada extremo. |
| jti | texto | sí | UUID v4, nuevo en cada pase. GlobalLink lo guarda 10 minutos y rechaza repeticiones. |
| texto | sí | RFC 5322. Se guarda y se usa para avisos; no es la llave de la cuenta. | |
| name | texto | sí | UTF-8, hasta 200 caracteres. |
| country | texto | sí | Dos letras minúsculas. Tiene que existir como país activo en GlobalLink; si no, rechazo con motivo pais_no_habilitado. |
| franchise_id | texto | si el rol es de oficina | El IdOficina de TNI, como texto. Tiene que existir en el espejo y pertenecer al país del pase. |
| role | texto | sí | Uno de los siete roles de GlobalLink: tni_admin · country_admin · auditor · franchise_owner · franchise_admin · franchise_manager · franchise_user. El tni_regional de la especificación de mayo no existe: es country_admin. Si TNI prefiere no resolver el rol en su página, puede mandar cargo con el IdCargo y GlobalLink lo resuelve con la tabla de R8. |
| language | texto | no | Uno de los diez idiomas de la plataforma; si falta, el del país. |
Documento JWKS (RFC 7517) en la dirección fija, servido por HTTPS con TLS 1.2 o superior, cacheable. Cada llave lleva kid, kty: "RSA", use: "sig", alg: "RS256", n y e. Para rotar, TNI publica la llave nueva junto a la vieja, empieza a firmar con la nueva, y retira la vieja después de 10 minutos; GlobalLink guarda el documento una hora y lo vuelve a pedir cuando llega un kid que no conoce. La llave privada vive en el servidor de TNI (almacén de certificados de Windows o equivalente) y nunca en código ni en variables de entorno.
La página emisora de TNI devuelve un documento HTML mínimo con un formulario que se envía solo. El pase viaja en el cuerpo, no en la dirección: así no queda en el historial del navegador, ni en el 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="token" value="eyJhbGciOiJSUzI1NiIs…">
<noscript><button>Entrar a GlobalLink</button></noscript>
</form>
<script>document.getElementById("f").submit();</script>
Cabeceras de la respuesta de esa página: Cache-Control: no-store y Referrer-Policy: no-referrer. Un pase nunca se escribe en un log del lado de TNI.
| # | Comprobación | Si falla | Motivo registrado |
|---|---|---|---|
| 1 | El cuerpo trae token con forma de JWT (tres partes) y menos de 8 KB. | 400 | pase_malformado |
| 2 | La cabecera dice alg: RS256 y trae kid. Ningún otro algoritmo se acepta, incluido none. | 401 | algoritmo_no_admitido |
| 3 | Existe una llave con ese kid en el JWKS (de caché o refrescado una vez) y la firma verifica. | 401 | firma_invalida · kid_desconocido |
| 4 | iss y aud coinciden con lo configurado. | 401 | emisor_o_destinatario_incorrecto |
| 5 | nbf ≤ ahora + 30 s, exp > ahora − 30 s, exp − iat ≤ 300. | 401 | pase_vencido · pase_demasiado_largo |
| 6 | jti no visto en los últimos 10 minutos. Se inserta en app.sso_pases_vistos en la misma transacción; si ya estaba, es un reintento o un replay. | 401 | pase_repetido |
| 7 | country existe y está activo; si el rol es de oficina, franchise_id existe en silver.oficinas y pertenece a ese país. | 403 | pais_no_habilitado · oficina_no_habilitada |
| 8 | role es uno de los siete; o cargo tiene fila en app.tni_cargo_rol. | 403 | rol_no_admitido · cargo_sin_equivalencia |
| 9 | Cuenta: se busca en public.users por id_empleado_tni = sub. Si no existe, se crea con external_sub = 'tni:' + sub, correo, nombre, rol, oficina y país del pase. Si existe, se actualizan nombre, correo, rol, oficina y país, y si estaba inactiva se rechaza. | 403 | cuenta_desactivada |
| 10 | Sesión: cookie tn_session firmada por GlobalLink (HS256, secreto propio), 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 su jti, dispositivo, IP y ubicación aproximada. Es la misma sesión que abre el login normal. | — | — |
| 11 | Respuesta: 303 See Other al tablero de la oficina. El pase no se guarda en ningún lado; ya no sirve. | — | — |
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, el sub y el jti. El pase mismo nunca se registra; el correo y el nombre se escriben sólo en esa tabla de auditoría, no en los registros de aplicación. Limite: 30 intentos por minuto por IP; por encima, 429 sin detalle.
Una página en el idioma del pase, o en español si no lo trae, 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.
| # | Prueba de aceptación | Resultado esperado |
|---|---|---|
| P1 | Pase válido de un vendedor de Guatemala | Entra al tablero de la oficina 679 con rol de vendedor; fila en auditoría con resultado ok |
| P2 | El mismo pase enviado dos veces | La segunda vez: 401 pase_repetido |
| P3 | Pase con exp a 10 minutos | 401 pase_demasiado_largo |
| P4 | Pase firmado con otra llave, o con alg: none | 401 firma_invalida o algoritmo_no_admitido |
| P5 | Pase con un cargo sin fila en la tabla | 403 cargo_sin_equivalencia, con la página de error y el enlace de vuelta |
| P6 | Pase de un empleado nuevo | Se crea la cuenta y entra; public.users.id_empleado_tni queda escrito |
| P7 | Pase de un empleado con fecha de baja en el espejo | 403 cuenta_desactivada; sus sesiones anteriores revocadas |
| P8 | JWKS inaccesible durante la prueba | Si hay caché vigente, entra; si no, 503 con mensaje y reintento sugerido; nunca se acepta un pase sin verificar |
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.
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 |
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 del pase | sí | id_empleado_tni |
| app.tni_cargo_rol · app.sso_pases_vistos | La equivalencia cargo → rol que llena TNI, y los números de pase ya usados | 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 haya pase | 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í | — |
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.
-- 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.
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.
| 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. El pase de acceso puede llevar el número de empleado |
| 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 |