Plano completo de ecoplagas.online

La segunda parte de Cómo se construyó ecoplagas.online. Aquella explica las ideas; esta baja al detalle: plataforma, permisos, reglas de seguridad, colecciones, funciones en la nube y cada módulo con su flujo, sus reglas de negocio y su pseudocódigo.

Está escrita para leerse con una IA que programe. No incluye llaves, identificadores ni datos de personas. Hay una versión en texto plano para pasársela completa. Por Pablo Paniagua, LATAM Code Labs; uso permitido según permisos.

Contenido
  1. Cómo usar este plano
  2. Plataforma y arquitectura
  3. Operación de campo: agenda, rutas y servicio
  4. Ventas, CRM y clientes
  5. Inventario, bodega y personal
  6. Gerencia, medición, flota y cumplimiento
00

Cómo usar este plano

Para qué sirve

Este documento describe ecoplagas.online con el detalle necesario para que una IA que programa (Claude Code u otra) pueda construir un sistema equivalente para otra empresa de servicios en campo: fumigación, limpieza, mantenimiento, instalaciones, reparaciones a domicilio. Describe qué hace cada módulo, qué datos guarda, qué reglas aplica y cómo se conectan las piezas.

No es código para copiar. Es la especificación de un sistema que ya funciona en producción todos los días, con las decisiones que se tomaron y por qué.

Cómo pasárselo a una IA

  1. Darle primero la guía de ideas: https://latamcodelabs.com/como-se-construyo-ecoplagas-online.txt.
  2. Darle después este plano: https://latamcodelabs.com/plano-ecoplagas-online.txt.
  3. Pegarle esta instrucción, completando los corchetes:
Vas a construir para [NEGOCIO] en [PAÍS] un sistema equivalente al
descrito en las dos guías que te pasé. No copies umbrales, horarios
ni porcentajes sin validarlos conmigo.

Fase 0: haceme las preguntas necesarias sobre mi operación (servicios,
roles, turnos, cómo vendo, cómo cobro, bodegas, flota) y escribí el
modelo de datos adaptado, con las colecciones y campos de la Parte 1.

Después construí por fases, en el orden del plano, y al terminar cada
fase: publicá, probá en vivo y decime qué número de negocio medimos.

Reglas fijas:

- Firebase (Hosting, Firestore, Functions, Auth, Storage), sin build
  step, HTML + JS directo, como en la Parte 1.

- Permisos por módulo y reglas de seguridad desde el día uno, con
  pruebas en el emulador.

- Las llaves de servicios externos solo como secretos de Functions.
- Nada se borra: cancelar, archivar y dejar historial.

Orden de construcción recomendado

El sistema creció durante 10 meses. Si se empezara hoy, este es el orden que tiene sentido, porque cada fase usa los datos de la anterior:

Fase Qué construir Partes del plano Por qué en este orden
1 Proyecto, login, empleados, permisos, header, reglas base, publicación automática Parte 1 Todo lo demás cuelga de saber quién es el usuario y qué puede abrir
2 Catálogos (servicios, productos), clientes y agenda de citas con historial Partes 1 y 2 La cita es el centro: venta, ruta, inventario y cobro salen de ella
3 Ruteador, vista del técnico, reporte de servicio con fotos, cobro Parte 2 Cierra el ciclo de la cita en campo
4 Inventario: bodegas, movimientos, gasto por cita, alisto Parte 4 Necesita citas y servicios con su receta de productos
5 WhatsApp oficial, conversaciones, embudo del vendedor, reparto Parte 3 Necesita empleados con turnos y la agenda para agendar
6 Horas, planilla, comisiones Parte 4 Necesita citas cobradas y vendedores asignados
7 Atribución de publicidad, reportes de canales, tableros Parte 5 Necesita leads con origen y citas con monto
8 GPS, alertas, puntualidad Parte 5 Necesita ruteador con vehículos
9 Capacitación, cumplimiento, portal de clientes, garantías Partes 3 y 5 Capas de calidad sobre un sistema ya estable
10 IA: bot de ventas, revisión de textos, simulador Partes 3 y 5 Solo cuando las reglas y los datos ya están firmes

Convenciones del plano

  • Los nombres de colecciones, campos, archivos y funciones son los reales del sistema, para que se entienda la relación entre piezas. Se pueden renombrar.
  • El pseudocódigo está en español y no depende de un lenguaje.
  • Donde dice "gerencia", "un vendedor" o "un técnico" se refiere a un rol, no a una persona.
  • Los números (minutos, metros, porcentajes) son los que funcionaron en EcoPlagas en Costa Rica en 2026.
  • No se incluyen llaves, identificadores de cuentas, teléfonos ni datos de personas.
01

Plataforma y arquitectura

Este capítulo describe lo que comparten todas las pantallas del sistema: cómo está armado el repositorio, cómo se publica, cómo entra y se identifica una persona, cómo se decide qué puede ver y tocar, qué servicios corren en todas las páginas (encabezado, tema, reporte de problemas, presencia, aviso de versión), cómo están escritas las reglas de seguridad de la base de datos, cómo se organizan las funciones de servidor y qué se aprendió sobre costos.

El sistema es una aplicación web interna de una empresa de control de plagas (agenda de citas, rutas de técnicos, inventario de bodegas, CRM de WhatsApp, planilla, reportes). Corre sobre Firebase: Hosting para los archivos, Firestore como base de datos, Firebase Auth para las cuentas, Cloud Storage para archivos y Cloud Functions (Node.js 22) para la lógica de servidor.

1. Estructura del repositorio y publicación

1.1 Principios de construcción

  • Sin paso de compilación. No hay bundler, transpilador ni npm run build para el frontend. Lo que está en public/ es exactamente lo que se sirve. Cada página es un .html autocontenido con su <script type="module"> en línea o con scripts propios al lado.
  • Todo por CDN. Firebase se importa con URL completa y versión fija (SDK modular 10.12.2 desde www.gstatic.com/firebasejs/10.12.2/...). Nunca import de npm en el navegador. Bootstrap 5.3.0, Font Awesome 6.4.0, Leaflet 1.9.4, Chart.js 4.4.0, jsPDF 2.5.1, html2canvas 1.4.1, JSZip 3.10.1, SheetJS (xlsx) 0.18.5 y QRCode 1.5.3 llegan de jsDelivr, cdnjs o unpkg.
  • Una página por módulo. Hay unas 290 páginas .html en public/ y unos 80 módulos JS en public/js/. Las páginas grandes (la agenda tiene unas 36 mil líneas) se fueron partiendo en archivos js/agenda-*.js que se cargan como scripts y se comunican por variables globales window.*.
  • Comunicación por globales. window.db (Firestore), window.firebaseAuth, window.secureAuthManager (alias window.authManager), window.escapeHTML, y en la agenda window.todasLasCitas, window.todosLosEmpleados, etc.
  • Cache busting manual con ?v=N en los src de scripts y hojas de estilo (reportar-problema.js?v=4, tema.js?v=2).
  • Todo en español con tildes correctas; moneda en colones sin decimales.
  • No hay ambiente de prueba. La rama main es producción. Varias personas y sesiones de IA empujan a main el mismo día, así que la regla es: pull --rebase antes de tocar y antes de empujar, agregar solo los archivos tocados por nombre, nunca push --force.

1.2 Carpetas

Carpeta Contenido
public/ Todo lo que se sirve: páginas .html, scripts transversales (auth-secure.js, check-permissions.js, load-headersecure.js, header.html, tema.js, reportar-problema.js, presence-tracker.js, track-location.js), estilos (eco-plagas-styles.css, unified-styles.css, css/).
public/js/ Módulos por pantalla o por función (agenda-*.js, planilla-core.js, cita-historial.js, version-watch.js, etc.).
public/shared/ Fuente única de reglas de negocio pequeñas: telefono.js (normalizar teléfonos), empleado.js (username, activo, gerencia), canales.js (canales de captación), moneda.js (formato de colones).
public/capacitacion/, public/blog/, public/docs/, public/quiz-data/ Material estático: videos de capacitación, documentos, datos de quizzes.
functions/ Cloud Functions. index.js (unas 9.300 líneas) más unos 50 módulos por dominio y functions/shared/ (espejo CommonJS de public/shared/).
scripts/ Lint de arranque (lint-arranque.mjs), prueba de los espejos shared (test-shared.mjs), scripts de diagnóstico y mantenimiento que corren desde GitHub Actions.
tools/ Agentes y utilidades que corren fuera de Firebase: el agente de WhatsApp de los teléfonos de empresa (en un servidor propio), receptor de GPS, generadores de reportes, migraciones.
.github/workflows/ Despliegue y diagnósticos (ver 1.4).
Raíz firebase.json, firestore.rules, firestore.indexes.json (54 índices compuestos), storage.rules, database.rules.json, storage-cors.json, deploy.sh (respaldo manual de emergencia).

1.3 firebase.json

Hosting

  • public: "public", con una lista ignore que excluye archivos ocultos, node_modules, respaldos (*.backup_*), scripts viejos de migración y diagnóstico que quedaron en la carpeta, y package.json.
  • Sin cleanUrls. /agenda da 404: los enlaces siempre llevan .html.
  • Rewrites: la raíz / sirve inicio.html; /E/** y /e/** sirven estacion.html (códigos QR de estaciones de cebo que se escanean en campo).
  • Redirects: enlaces cortos con parámetros. Ejemplos del patrón: /m/:fecha/:turno → mapa_rutas.html?fecha=:fecha&t=:turno (302), /c/:fecha/:cita → agenda.html?fecha=:fecha&cita=:cita&ver=1 (302), y páginas renombradas con 301.
  • Headers por tipo de archivo:
Patrón Cache-Control Otros
**/*.html no-cache, must-revalidate X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (geolocalización y micrófono solo del propio sitio, cámara desactivada), HSTS de un año, y una Content-Security-Policy que lista exactamente los CDN, los dominios de Firebase/Google, los servicios de mapas (Nominatim, OSRM) y el dominio de funciones permitidos.
**/*.js, **/*.css max-age=300, must-revalidate Tras un deploy, el peor caso son 5 minutos con JS viejo.
/capacitacion/video/** public, max-age=31536000, immutable CORS abierto (los videos no cambian de nombre).
/capacitacion/**/*.html public, max-age=300 X-Frame-Options: ALLOWALL (se incrustan en otro sitio).

Los patrones van duplicados con y sin **/ (*.html y **/*.html) para cubrir la raíz y subcarpetas.

Otros servicios declarados: firestore (reglas e índices), database (reglas de Realtime Database, usada solo para posición en vivo de la flota), storage (reglas), functions (un solo codebase default desde functions/).

1.4 Workflows de GitHub Actions

Workflow Disparo Qué publica o hace
firebase-deploy.yml Todo push a main y manual 1) Corre el lint de arranque (informativo, continue-on-error). 2) Escribe public/version.json con {"v": <12 primeros caracteres del commit>, "cuando": <ISO UTC>}. 3) Publica Hosting al canal live. 4) Publica reglas de Firestore y de Storage. 5) Aplica la configuración CORS del bucket de Storage (no bloquea si falla).
firebase-deploy-functions.yml Push a main que toque functions/**, y manual npm ci en functions/ y firebase deploy --only functions --force.
pauta-spend-update.yml Solo manual, con parámetros (dry_run, desde, hasta, forzar) Carga o completa el gasto diario de publicidad en la colección pauta_spend.
diagnostico-*.yml (10 archivos) Solo manual, con parámetros (fecha, ruta, usuario, cita) Scripts de solo lectura que imprimen en el log del Action por qué algo se ve como se ve: login de un usuario, fotos de un reporte, faltantes de despacho, puntos de GPS, etc. Sirven para diagnosticar producción sin abrir la consola.
restaurar-fotos-reporte.yml, dar-permiso-*.yml, pasar-citas-*.yml Solo manual Scripts que escriben, de un solo uso o con modo seco por defecto (simular/aplicar).

Concurrencia. Los dos workflows de deploy tienen concurrency: { group: <nombre del workflow>, cancel-in-progress: false }. Motivo: dos pushes seguidos corrían dos deploys en paralelo y el que terminaba último pisaba al otro aunque fuera más viejo. Con la cola, los deploys salen en orden y siempre gana el más reciente.

Lo que no se publica solo: los índices de Firestore (firestore.indexes.json) se despliegan a mano. Si el Action no corre (pasó una vez por un problema de pago de GitHub), se publica a mano desde main limpio (sin cambios sin commitear), y para functions con la variable FUNCTIONS_DISCOVERY_TIMEOUT=180 porque el descubrimiento de funciones de un index.js grande supera el timeout por defecto de 10 s.

Credencial: todos los workflows usan una cuenta de servicio guardada como secreto del repositorio; se escribe a un archivo temporal del runner y se exporta como GOOGLE_APPLICATION_CREDENTIALS.

1.5 Lint de arranque

npm run lint (scripts/lint-arranque.mjs, Node puro, sin dependencias) recorre public/ y revisa:

Regla Tipo Qué detecta
R1 error Un HTML que carga dos veces el mismo <script src> (ignora ?v=). Duplica listeners y lecturas.
R2 error Una página que carga auth-secure.js o check-permissions.js pero no figura en PAGE_PERMISSIONS ni calza con PREFIJOS_PERMISOS. Sin entrada, la abre cualquier empleado. Lee los dos objetos ejecutando el archivo en un vm aislado, así no hay copia que mantener. Lista blanca para páginas públicas (encuesta, garantía, verificar certificado, privacidad, términos, etc.).
R3 error id="..." repetido en el marcado estático de un HTML.
R4 error new Date(...).toISOString().slice(0, 10) en la misma línea (fecha en UTC: de 6 pm a medianoche en Costa Rica ya es "mañana"). Se puede marcar una línea correcta a propósito con un comentario. Revisa también functions/.
R5 aviso Páginas huérfanas (nadie las enlaza).
R6 aviso Funciones locales de escape (esc, escapeHtml) que no escapan comillas.

Hoy corre en modo informativo porque hay hallazgos de línea base; la intención es volverlo bloqueante cuando R1 a R4 lleguen a cero.

scripts/test-shared.mjs verifica que public/shared/*.js (ES modules) y functions/shared/*.js (CommonJS) den exactamente el mismo resultado.

2. Autenticación

2.1 Identidad: usuario, correo sintético, ficha y lookup

  • La persona escribe un username (por ejemplo usuario.ejemplo). El sistema lo convierte en el correo interno de esa cuenta y hace signInWithEmailAndPassword en Firebase Auth.
  • El username canónico se obtiene de ese correo, en minúsculas y sin tildes. Hay una sola definición (shared/empleado.js: usernameDesdeCorreo) con espejo en functions/.
  • Ser empleado = tener ficha, no basta el dominio del correo. El registro de cuentas de Firebase Auth quedó abierto (no se puede cerrar sin romper el inicio de sesión anónimo de una página pública), así que cualquiera podría crearse una cuenta con el dominio de la empresa. Por eso la identidad real es:
empleados_uid_lookup/{uid}  ->  { empleadoId }
empleados/{empleadoId}      ->  ficha: username, primerNombre, primerApellido,
                                 departamento, cargo, estado, permisos{}, firebaseAuthUID,
                                 firebaseAuthEmail, fotoPerfil, nombrePreferido, tipoPago...
empleados/{empleadoId}/privado/datos -> salario, cuenta bancaria, cédula, tarifas
  • El 70% de las fichas tiene id aleatorio, por eso el puente empleados_uid_lookup es imprescindible: las reglas de Firestore resuelven quién sos leyendo esa fila.
  • La fila del lookup solo puede apuntar a una ficha que ya tenga ese mismo firebaseAuthUID y cuyo username coincida con el del correo (ver 5.1). La ficha la crea quien administra personal.

2.2 Página de inicio de sesión (iniciar_sesion.html)

Pasos:

  1. Normaliza el username, arma el correo sintético y llama signInWithEmailAndPassword.
  2. Si Firebase acepta la clave, marca credencialValida = true. Desde ahí ningún error posterior se reporta como "credenciales incorrectas".
  3. Carga la ficha con un tope de 25 s (Promise.race): primero empleados_uid_lookup/{uid} → empleados/{empleadoId}; si no hay lookup, busca la propia ficha con where('firebaseAuthUID','==',uid) y crea su propia fila de lookup (primer inicio de un empleado nuevo). Como respaldo intenta la lista completa de empleados y compara por correo, uid o username.
  4. Redirige al destino pendiente o a inicio.html.

Clasificación de errores que ve la persona:

Situación Mensaje
Red caída, auth/network-request-failed, unavailable "No se pudo conectar al servidor..."
auth/too-many-requests "Demasiados intentos... temporalmente bloqueado". Es el único freno de fuerza bruta: el del servidor de Firebase. Un limitador en memoria que existía nunca corría y se retiró.
auth/user-disabled o ficha desactivada "Tu cuenta está desactivada. Contactá a gerencia."
Clave válida pero sin ficha "No encontramos tu ficha de empleado."
Clave válida pero falló otra cosa "Tu usuario y contraseña están bien, pero no se pudieron cargar tus datos" + detalle técnico chico, y se anota en la colección diagnostico_login (usuario, uid, código, mensaje, navegador, página, hora).
Cualquier otro "Credenciales incorrectas" (no revela si el usuario existe).

Destino después del login (postLoginRedirect). Cuando una página manda al login, guarda en sessionStorage {url, username} con el dueño. Al entrar, el destino solo se usa si el que entra es el mismo dueño (o si no tenía dueño, caso de un enlace directo abierto sin sesión). Motivo: teléfonos y tabletas se comparten en campo y el siguiente usuario caía en la página del anterior con "Acceso denegado". Si no hay destino guardado, vuelve a la página de origen (document.referrer) una vez cada 30 s como máximo, para no rebotar en círculo.

Parámetros que muestran aviso: ?sesion=expirada ("Se cerró la sesión, volvé a entrar y seguís donde estabas") y ?timeout=1.

2.3 auth-secure.js y la clase SecureAuthManager

auth-secure.js es un módulo ES que toda página interna importa. Hace, en orden:

  1. Inicializa la app de Firebase (la configuración pública del proyecto va en el archivo o en window.firebaseConfig), getAuth, getFirestore y publica window.db.
  2. Caché de Firestore en memoria solamente. Se probó persistentLocalCache con gestor multipestaña para ahorrar transferencia (la recarga de la agenda bajaba de 2,7 MB a 85 KB) y se revirtió a los tres días porque provocaba INTERNAL ASSERTION FAILED: Unexpected state al guardar citas con dos pestañas abiertas escribiendo en paralelo (agenda y CRM). Quedó documentado qué probar antes de reintentarlo: subir el SDK o usar gestor de una sola pestaña, y la prueba obligatoria de guardar una cita con dos pestañas.
  3. Define PERMISOS_HEREDADOS y permisosConHerencia() (ver 3.4).
  4. Instala window.FirestoreReadTracker: un registrador opcional de lecturas que encola entradas (usuario, página, colección, función, cantidad de filas, sesión, dispositivo, navegador, conexión) y las escribe en lotes de 20 en diagnostico_lecturas_firestore. Las páginas lo llaman a mano en sus consultas pesadas para medir quién lee qué.
  5. Intenta App Check con reCAPTCHA v3 solo si hay una clave configurada (hoy no).
  6. Expone window.firebaseAuth y window.sesionFirebaseLista = auth.authStateReady(). Si restaurar la sesión tarda más de 2,5 s, muestra un aviso fijo "Verificando tu sesión..." fuera del body (que está oculto).
  7. Crea window.secureAuthManager = new SecureAuthManager().
  8. Importa por efecto lateral presence-tracker.js y docs-check.js (aviso de documentos personales faltantes después del login).

Estado y métodos de SecureAuthManager:

Miembro Qué hace
currentUser Objeto de sesión: id, username, nombre, cedula, tipoPago, permisos (ya con herencia), departamento, fotoPerfil, firebaseUID, firebaseEmail, loginTime.
onAuthStateChanged Con usuario: marca authTuvoSesion en sessionStorage y carga la ficha. Sin usuario: limpia y llama redirigirPorSesionPerdida.
loadUserDataFromFirestore(fbUser) Resuelve la ficha con resolveEmployeeForAuth(force), bloquea si está dada de baja, arma currentUser, guarda en sessionStorage. Si la firma de permisos cambió respecto de lo anterior, pone window._permisosCambiaron = true y dispara el evento permisos-actualizados para que el menú se repinte sin recargar. Compara con una firma ordenada de las claves: comparar JSON.stringify a secas daba "distinto" por el orden de claves y producía un bucle de unas 6 relecturas por segundo.
lastLoadError {motivo: 'desactivado' | 'sin_ficha' | 'red', intentos, fecha}. Lo que no se puede clasificar con certeza cuenta como red (se reintenta).
motivoDeFalloAdministrativo() Devuelve desactivado de inmediato, o sin_ficha solo después de 3 intentos (porque una lectura fallida puede verse como ausencia). Es lo que corta los reintentos.
isAuthenticated() auth.currentUser != null && currentUser != null.
hasPermission(clave) currentUser.permisos[clave] === true.
refreshUserPermissions() Relee la ficha; si la cuenta resultó desactivada, cierra sesión y manda al login.
waitForCurrentUser(8000) Espera hasta 8 s, primero con lo guardado en sessionStorage y luego cargando la ficha si Firebase ya tiene usuario.
login(), logout() logout marca _logoutVoluntario antes del signOut para que el listener no lo trate como sesión caída, y borra el destino pendiente.
createUserInFirebaseAuth() Alta de cuenta para un empleado: llama a la función crearEmpleadoAuth con el token del administrador (no usa createUserWithEmailAndPassword en el cliente) y escribe en la ficha firebaseAuthUID, firebaseAuthEmail, firebaseAuthMigrated.

Relojes internos:

Reloj Intervalo Comportamiento
Inactividad cada 60 s Cierra sesión tras 7 días sin interacción (mouse, teclado, toque, scroll). Antes era 1 día y castigaba a quien deja la pestaña abierta de un día para otro. El cierre automático de domingos a medianoche se retiró.
Latido del token cada 5 min getIdToken(true). Solo cinco códigos significan sesión muerta (user-disabled, user-token-expired, user-not-found, invalid-user-token, user-mismatch), y hacen falta dos latidos seguidos con ese código para expulsar. Cualquier otro error (incluido uno sin código por mala señal) se ignora y se reintenta.
Refresco de permisos cada 2 min (en check-permissions.js) Relee la ficha y repinta el menú con lo que hay en memoria.
Sesión perdida 3 s de gracia redirigirPorSesionPerdida solo actúa si en la pestaña ya hubo sesión (authTuvoSesion), una sola vez, y espera 3 s por si el token se restaura solo; luego iniciar_sesion.html?sesion=expirada.

Claves de sessionStorage: currentUser (solo datos no sensibles), firebaseAuthToken = authenticated (bandera, no es un token), employeeStoreCacheV1, postLoginRedirect, authTuvoSesion, ultimoReboteLogin, firestoreReadTrackerSessionId. No se usa localStorage para autenticación.

2.4 employee-store.js

Caché compartido de fichas de empleados para unas 40 páginas.

  • Índices en memoria por username, uid y correo, hidratados desde sessionStorage['employeeStoreCacheV1'] con TTL de 15 min.
  • Nunca cachea campos sensibles: cedula, fechaNacimiento, telefonoPersonal, direccion, banco, cuentaBancaria, tarifas_jornada, tope_servicios_profesionales, salario, precio_hora_sencilla_por_mes. Esos viven en la subcolección privado y las pantallas que los usan leen fresco.
  • resolveEmployeeForAuth(db, {uid, email, username}, {force}): orden de búsqueda uid (lookup o ficha propia, creando el lookup si falta) → username (documento con ese id o where('username')) → firebaseAuthEmail.
  • getAllEmployees(): lee la colección entera solo si el caché está vencido; si falla y no hay caché, relanza el error (devolver lista vacía hacía que el login concluyera "usuario no existe" y cerrara la sesión por un corte de red).
  • clearEmployeeStoreCache() se llama al cerrar sesión.

2.5 check-permissions.js

Script clásico (no módulo), síncrono, en el <head> de casi todas las páginas. Contiene:

  • window.escapeHTML (escapa & < > " '), definido acá porque es el primer archivo que carga.
  • PAGE_PERMISSIONS: mapa nombre_de_archivo.html → clave o → [claves] (con lista, alcanza con tener una).
  • PREFIJOS_PERMISOS: quiz- → capacitacion, admin_inventario_ → administrador_bodega, proc_mp → procedimientos_salud.
  • checkPagePermissions(): si la página no tiene entrada, la deja pasar (por eso existe la regla R2 del lint). Si tiene, espera al manager, refresca permisos y muestra "Acceso denegado" si falta la clave.
  • Pantallas de bloqueo: Acceso denegado (con sesión viva ofrece volver al Menú o cerrar sesión, no manda al login) y Cuenta bloqueada ("Cuenta desactivada" o "Ficha de empleado no encontrada", con el texto "Reintentar no cambia nada: lo tiene que corregir gerencia" y botón de cerrar sesión). Ambas destapan el body antes de pintarse.
  • Al cargar el DOM espera hasta 15 s (75 vueltas de 200 ms) al manager. Si hay fallo administrativo, corta los reintentos (antes eran diez minutos de lecturas inútiles). Si pasa el tiempo pero Firebase tiene sesión viva, no expulsa (la red puede estar lenta y el refresco periódico la recupera).
  • Intervalos: refresco de permisos cada 2 min y badge de tickets cada 30 s.
  • Carga verificar-documentos.js en dos páginas de técnicos que no tienen encabezado.

2.6 Bloqueo y baja de cuentas

  • Criterio único de baja (navegador y servidor): bloqueado si estado es "Inactivo" o "Suspendido" (sin importar mayúsculas), o activo === false, o estado === false. Estado vacío o "Vacaciones" cuenta como activo.
  • Al dar de baja (trigger empleadoBajaOnWrite sobre empleados/{id}): deshabilita la cuenta de Auth, revoca los refresh tokens y borra empleados_uid_lookup/{uid}, con lo que isEmpleado() deja de pasar en todas las reglas sin tocarlas. Al volver a "Activo" rehabilita y recrea el lookup. Es idempotente y deja rastro en empleados_baja_log.
  • En el navegador, la próxima relectura (máx. 2 min) detecta la baja, cierra sesión y muestra "Cuenta desactivada".
  • No existe bloqueo propio por intentos fallidos: lo aplica Firebase Auth (too-many-requests).

2.7 Ciclo de carga de una página protegida

Cada página interna sigue esta plantilla (la de referencia es bodeguita.html):

<head>
  <link bootstrap, font-awesome, eco-plagas-styles.css, unified-styles.css>
  <script src="check-permissions.js">            // síncrono, define escapeHTML y PAGE_PERMISSIONS
  <script type="module">
     import { auth } from './auth-secure.js'      // inicia Firebase, crea secureAuthManager,
                                                   // arranca presencia
     import './track-location.js'
     ...guardia de sesión (abajo)
  </script>
  <style> body { display:none !important } body.authenticated { display:block !important } </style>
</head>
<body>
  <div id="header-container"></div>
  <script src="load-headersecure.js">            // trae header.html y servicios globales
  ...contenido...
</body>

Pseudocódigo del ciclo:

AL CARGAR LA PÁGINA:
  ocultar body
  esperar auth.authStateReady()           // Firebase terminó de restaurar la sesión guardada
                                          // (si tarda > 2,5 s, aviso "Verificando tu sesión...")
  si no hay auth.currentUser tras ~10 s:
      guardar postLoginRedirect {url, username}
      ir a iniciar_sesion.html
  window.authManager = secureAuthManager
  mostrar body (clase "authenticated")

EN PARALELO (secureAuthManager, onAuthStateChanged):
  ficha = resolver por empleados_uid_lookup/{uid} -> empleados/{id}
          (si no hay lookup: ficha propia por firebaseAuthUID y crear lookup)
  si ficha dada de baja -> error "desactivado"
  currentUser = { username, nombre, departamento, permisos: conHerencia(ficha.permisos), ... }
  guardar en sessionStorage

EN PARALELO (check-permissions.js, DOMContentLoaded):
  clave = PAGE_PERMISSIONS[archivo] o por prefijo
  si no hay clave -> página abierta a cualquier empleado
  repetir cada 200 ms hasta 15 s:
      si manager autenticado:
          refrescar permisos
          si no tiene ninguna de las claves -> pantalla "Acceso denegado"
          salir
      si fallo administrativo (desactivado o 3 veces sin ficha) -> pantalla "Cuenta bloqueada"
  si se agotó el tiempo:
      si Firebase tiene sesión viva -> quedarse (no expulsar)
      si no -> login
  cada 2 min: refrescar permisos y repintar el menú
  cada 30 s: badge de tickets

EN PARALELO (load-headersecure.js):
  fetch('header.html') e insertarlo
  filtrar módulos por permiso, ordenar por departamento, repartir en "Otros"
  inyectar: tickets-badge, reportar-problema, tema, version-watch, politica-promesas-ui,
            verificar-documentos, aviso de actividades
  escuchar 'permisos-actualizados' para repintar

CADA 60 s: presence-tracker escribe latido (si la pestaña es líder)
CADA 5 min: latido del token; track-location re-captura GPS si la pestaña está visible
CADA 60 min: version-watch mira version.json

Regla de oro documentada en el código: nunca mandar al login por un reloj solo. En redes móviles lentas Firebase puede tardar más que cualquier timeout fijo en restaurar una sesión válida; siempre se espera authStateReady() y se verifica currentUser antes de expulsar.

2.8 Alta de empleado y SSO de la app nativa

  • Alta: gerencia crea la ficha en empleados.html y presiona "Crear en Firebase Auth". Eso llama la función HTTP crearEmpleadoAuth (exige permiso empleados del lado servidor), que crea la cuenta con el Admin SDK y devuelve el uid. La página escribe el uid en la ficha y el lookup (las reglas permiten a quien tiene empleados escribir el lookup de otro uid solo si es coherente con la ficha).
  • App Android: la función callable mintWebToken recibe la sesión de la app y devuelve un custom token; un WebView hace signInWithCustomToken y entra a las páginas web con la misma sesión, sin segundo login.

3. Sistema de permisos

3.1 Modelo

  • Los permisos son un mapa de booleanos en la ficha: empleados/{id}.permisos = { calendario: true, ruta: true, ... }.
  • Un permiso por botón del menú, no por departamento. Así se puede dar uno sin dar otro y revocar sin efectos colaterales. Regla de diseño: todo módulo nuevo nace con su propia clave, que hereda de reportes_gerenciales (en la práctica, gerencia) hasta que alguien la marque explícitamente.
  • Gerencia no es un permiso sino un criterio: departamento o cargo que contiene "gerenc" o "gerent" (sin tildes, sin mayúsculas). En el servidor además vale permisos.gerencia === true. Gerencia pasa todos los exigirPermiso del servidor y muchas excepciones de reglas.
  • Se verifica en tres capas: la página (check-permissions.js, solo interfaz), las reglas de Firestore (permiso('clave'), solo para colecciones con bloque propio) y las Cloud Functions (exigirPermiso).

3.2 Tabla completa de claves

La columna "Etiqueta" es el nombre de la casilla en la pantalla de Equipo (MODULOS_ACCESO de empleados.html), que coincide con el botón del menú.

Clave Etiqueta Páginas que abre Hereda de (si la clave no existe en la ficha)
calendario Agenda agenda.html, mapa_agenda.html, compas.html, mapa_rutas.html, reporte_sin_ruta.html; también certificados_seccion.html (base)
logistica Logística logistica.html calendario
ruteador Ruteador ruteador.html calendario
empleados Equipo empleados.html, gafetes.html, migrar_telefono.html; actas_quiz.html (con Reportes) (base, crítica en reglas)
politicas Políticas politicas.html reportes_gerenciales
planilla Planilla planilla.html, historico_planillas.html (base, crítica en reglas)
liquidaciones Liquidación liquidacion.html reportes_gerenciales
registro_horas Horas registro_horas.html (base)
edicion_horas Alertas edicion_horas.html (base)
reportes_gerenciales Reportes reportes.html, problemas_reportados.html, revision_ubicaciones.html, unas 50 páginas de reportes, documentación interna y auditoría (base, crítica en reglas)
pulso Pulso pulso.html, reportes_gerenciales.html (base)
operario_bodega Bodeguita bodeguita.html (base)
administrador_bodega Bodega administrador_bodega.html, todas las admin_inventario_*.html, catalogo_productos.html, auditorías de stock (base)
arqueo_inventario Arqueo Inv. arqueo_inventario.html, etiquetas_bodegas.html, saldos_minimos.html administrador_bodega
traslado_expres Traslado traslado_expres.html, traslado_flash.html administrador_bodega
guachiman Guachimán guachiman.html (auditoría visual de bodega) administrador_bodega
reposicion Reposición reposicion.html reportes_gerenciales
despacho Despacho despacho.html (base)
gps GPS Gerencia orion.html; entra también a gps360.html (base)
gps_ventas GPS Ventas entra a gps360.html gps (solo en la pantalla de Equipo)
gps360 GPS 360 gps360.html (y redirecciones gps.html, gps_gerencia.html, gps_ventas.html) reportes_gerenciales
tabla Tabla de citas tabla.html (también entra con Reportes) reportes_gerenciales
publicidad Publicidad publicidad.html reportes_gerenciales
respuestas_coordinacion Resp. coord. respuestas_coordinacion.html reportes_gerenciales
satisfaccion_clientes Satisfacción satisfaccion_clientes.html reportes_gerenciales
promesas_bloqueadas Promesas promesas_bloqueadas.html reportes_gerenciales
inteligencia_sitio Intel. sitio inteligencia_sitio.html reportes_gerenciales
videos_publicitarios Videos public. videos_publicitarios.html reportes_gerenciales o embudo_vendedor o whatsapp
videos_reuniones Reuniones videos_reuniones.html reportes_gerenciales
cotizaciones Cotizaciones cotizaciones.html reportes_gerenciales o embudo_vendedor
cotizaciones_aprobar Aprobar cotiz. (botón dentro de Cotizaciones: aprobar precio final) reportes_gerenciales
bot_ventas Vend. virtuales bot_ventas.html reportes_gerenciales
cumplimiento_salud Cumplimiento cumplimiento_salud.html, hoja_servicio.html reportes_gerenciales
procedimientos_salud Procedimientos procedimientos_salud.html, plan_residuos.html, proc_mp01..19.html reportes_gerenciales
whatsapp_empresa WA empresa whatsapp_empresa.html (vista de gerencia con auditoría) reportes_gerenciales
whatsapp_empresa_propio WA ventas whatsapp_empresa.html (vista de ventas, sin auditoría) (base)
portal_clientes_admin Cartera cartera.html, portal_clientes_admin.html, portal_clientes_mapa.html reportes_gerenciales
garantias Garantías garantias.html (también entra con Reportes) reportes_gerenciales (solo en la pantalla de Equipo)
tickets Tickets tickets.html (base)
ruta Ruta ruta.html, ubicaciones_mapa.html, timeline-ruta.html (base)
suplantar Ver como (botón en Ruta: ver la pantalla de otro técnico en modo solo lectura) (base)
actividades Actividades actividades.html (base)
actividades_admin Activ. admin (dentro de Actividades: cargar la lista y ver precios) (base)
ventas_calle Ventas mis_ventas.html (base)
comisiones Táctico tactico.html (base)
mini_tactico Mini táctico mini-tactico.html reportes_gerenciales
tactico_ventas (sin casilla, retirado) tactico-ventas.html reportes_gerenciales
oficinista_calle Calle oficinista_calle.html (base)
mapa Mapa mapa.html (base)
lealtad Lealtad lealtad.html, recuperador-unica-vez.html (base)
recuperador_cartera Recuperador recuperador_cartera.html reportes_gerenciales
atlas Atlas atlas.html (base)
conocimiento Conocimiento conocimiento.html (base)
ecobot EcoBot ecobot.html (base)
leads Leads leads.html (base)
whatsapp Embudo whatsapp.html, embudo.html, crm.html, crm2.html, raspa-masa.html, whatsapp-analitica.html, plantillas-*.html, crear-plantilla*.html, exportar_telefonos_meta.html (base)
embudo_vendedor Vendedor embudo_vendedor.html, conversion_vendedores.html, reasignar-leads.html (esta exige además gerencia por dentro) whatsapp
(varias) horarios_ventas.html (whatsapp o embudo_vendedor), leads_por_hora.html (embudo_vendedor, whatsapp o Reportes)
herramientas_admin Asignación herramientas_admin.html, alisto-herramientas-audit.html (base)
herramientas Herramientas herramientas.html (base)
capacitacion Capacitación capacitacion.html, simulador_campo.html, todas las quiz-*.html (base)
roi_pauta ROI roi-pauta.html, reporte_campanas.html (base)
splitwise Splitwise splitwise.html (base)
habilitado Autorizado (bandera usada por la agenda para habilitar acciones de vendedor) (base)
arqueo Arqueo Pagos arqueo.html (base)
cxc CxC cxc.html (base)
leads_b2b B2B leads_b2b.html, plantilla_correo_leads_b2b.html, credenciales_b2b.html (base)
automatizaciones Flujos automatizaciones.html (base)
anular_monto_cita Anular monto (botón para poner en ₡0 una cita sin ruta) No hereda a propósito: nace apagado y se marca persona por persona, porque deja anular ventas.
gerencia (sin casilla) Atajo de gerencia en el servidor

Páginas sin entrada en el mapa ni prefijo (abiertas a cualquier empleado con sesión): menu.html, perfil.html, inicio.html, iconos-comunidad.html, index.html. Páginas públicas (sin sesión): encuesta.html, garantia.html, verificar-certificado.html, cliente.html, certificado_fumigacion.html, privacidad.html, terminos.html, eliminacion-datos.html, wa.html (con sesión anónima), reporte_cliente_citas.html, estacion.html.

3.3 Herencia de claves

Cuando un acceso se separó en dos, o nació un módulo nuevo, la clave nueva se hereda de una vieja mientras no exista en la ficha. En cuanto gerencia guarda a esa persona en Equipo, todas las claves quedan escritas explícitamente y la herencia deja de aplicar (ahí sí se puede quitar Ruteador sin quitar Agenda). Es derivación en memoria, nunca escribe en Firestore.

función permisosConHerencia(permisos):
  p = copia(permisos)
  para cada (nueva, padres) en PERMISOS_HEREDADOS:
      si p[nueva] ya existe (true o false): seguir      // explícito manda
      padres = lista(padres)
      p[nueva] = alguno de padres tiene p[padre] == true
  devolver p

La tabla vive en tres copias que deben mantenerse iguales a mano: public/auth-secure.js (la que decide en el navegador), public/empleados.html (para que la pantalla de Equipo muestre el acceso real) y functions/auth-empleado.js (servidor). Además, las reglas de Firestore no aplican la tabla: los bloques que lo necesitan repiten la herencia en línea con misPermisos().get('clave', misPermisos().get('reportes_gerenciales', false)).

Diferencias detectadas al 31-oct-2026 entre las copias: empleados.html agrega garantias ← reportes_gerenciales y gps_ventas ← gps, que no están en auth-secure.js; la copia del servidor no tiene respuestas_coordinacion, satisfaccion_clientes, videos_reuniones, cotizaciones ni cotizaciones_aprobar, y conserva una clave retirada. Quien reconstruya el sistema debería tener una sola definición compartida (como ya se hizo con shared/telefono.js).

3.4 Cómo se agrega un módulo nuevo

  1. public/check-permissions.js: PAGE_PERMISSIONS['nuevo.html'] = 'nuevo' (y la clave en la lista de hasAccessToOtherModules).
  2. public/auth-secure.js: PERMISOS_HEREDADOS.nuevo = 'reportes_gerenciales', con comentario de la fecha de nacimiento. Si lo usa también el servidor, la misma línea en functions/auth-empleado.js.
  3. public/header.html: el enlace <a href="nuevo.html" class="mobile-nav-item" data-permission="nuevo" title="..."> con su ícono.
  4. public/load-headersecure.js: título y subtítulo en pageConfigs, y opcionalmente color en COLOR_MODULOS, grupo en GRUPOS_MODULOS y puesto en ORDEN_MODULOS.
  5. public/empleados.html: entrada en MODULOS_ACCESO, entrada en su PERMISOS_HEREDADOS local, casilla permisoNuevo en el modal (el id se deriva de la clave en CamelCase), y la lectura con herencia y escritura de esa casilla al guardar.

Si el módulo tiene colecciones sensibles: bloque propio en firestore.rules y agregar el nombre a la lista de exclusión del comodín (ver 5.2).

4. Encabezado y servicios transversales

4.1 header.html y load-headersecure.js

load-headersecure.js es un script clásico que define la clase HeaderLoader (instancia en window.HeaderLoader.instance):

  • Hace fetch('header.html') y lo inserta en #header-container (lo crea si no existe). Agrega Font Awesome si la página no lo trae y un favicon SVG en línea si falta.
  • pageConfigs: unas 96 entradas archivo.html → {title, subtitle} con ícono.
  • Marca como activo el botón del módulo actual; las subpáginas se resuelven a su módulo padre con reglas exactas y por prefijo.
  • Filtrado por permiso: cada <a class="mobile-nav-item"> lleva data-permission="clave" o "clave1,clave2" (alcanza con una). Sin atributo, lo ve todo el mundo. data-sin-permiso="clave" oculta el botón a quien sí tiene esa otra clave (el Mini táctico no le sale a quien ve el Táctico completo). La rejilla está invisible hasta terminar de filtrar, para no mostrar un instante módulos ajenos.
  • Orden por departamento: ORDEN_MODULOS tiene una lista por departamento (Gerencia, Ventas, Fumigación, Bodega, Administración) y una general *. Se calculó contando días-persona de uso por página en una semana (de los registros de dim_location) y se pega a mano. Si la ficha no trae departamento se deduce de los permisos (Reportes → Gerencia, Vendedor → Ventas, Ruta sin Agenda → Fumigación).
  • Máximo 15 visibles. El resto pasa a un panel "Otros" con buscador (sin tildes) y agrupado por área (GRUPOS_MODULOS: Rutas y citas, Ventas, Clientes, Bodega, Personal, Dinero, Gerencia, Ministerio de Salud, y "Más" para lo no clasificado). El botón "Otros" muestra el conteo.
  • Color fijo por módulo (COLOR_MODULOS), elegido para que no se repita entre los 10 primeros de cada departamento.
  • En celular la rejilla vive detrás de un botón; con mouse se cierra al salir del panel (2,5 s de gracia), en táctil solo con un toque afuera (los eventos de mouse simulados tras un toque la cerraban).
  • Escucha permisos-actualizados y revisa durante 15 s la bandera _permisosCambiaron por si el cambio llegó antes de que el header existiera.
  • Muestra la foto de perfil en el botón Perfil; el botón Salir llama logout().

Scripts que inyecta en todas las páginas con header: tickets-badge.js (cuenta tickets pendientes con getCountFromServer), reportar-problema.js, politica-promesas-ui.js, js/version-watch.js, tema.js, verificar-documentos.js (portón de documentos obligatorios para técnicos) y el aviso de la actividad abierta. Cada inyección se protege con una bandera window._xCargado y comprobando si el <script> ya existe.

Tiempo activo: en el mismo archivo, un latido de 60 s mantiene viva la sesión de trabajo abierta (work_sessions con status: 'working' y modo: 'tiempo_activo') actualizando lastActivity; tras 2 horas sin interacción la cierra con closedBy: 'inactividad_automatica' y endTime = última actividad. Al cargar, cierra las sesiones viejas que superen ese umbral.

4.2 tema.js (Claro, Suave, Oscuro)

Botón junto al bicho con tres temas. No repinta las páginas (son cientos, con colores escritos a mano): aplica un filter sobre <html>, el único elemento donde un filtro no rompe los position: fixed.

  • Suave: brightness(.86) contrast(.96) sepia(.16) saturate(.95).
  • Oscuro: invert(1) hue-rotate(180deg), y re-invierte fotos, videos, canvas, iframes, mapas y avatares para que se vean normales. Si algo sale invertido, se agrega su selector a la lista de re-inversión, no colores nuevos.
  • Se guarda en localStorage (aplica sin destello) y en preferencias_usuario/{uid}.tema (la cuenta manda sobre el dispositivo).

4.3 reportar-problema.js (botón del bicho)

Botón naranja en la barra (o flotante si la página oculta el header). Abre un formulario: qué pasó (texto o dictado), qué esperaba, qué tanto lo afecta, y pantallazos (captura con html2canvas, archivos o pegar con Ctrl+V).

Desde que carga arma una bitácora en memoria: intercepta console.error/warn, errores JS no atrapados y fetch que fallan (status ≥ 400 o sin respuesta; la URL va sin query por si trae un token). Al enviar agrega solo: usuario y permisos, página, URL, título, fecha en UTC y en Costa Rica, tamaño de ventana y pantalla, zoom, navegador, sistema, conexión, modales abiertos con extracto de su texto, inputs de fecha visibles, parámetros de URL, y lo que la página aporte con window.reportarProblemaContexto().

Guarda en la colección reportes_problemas y los pantallazos en Storage reportes_problemas/<id>/N.jpg; ambas rutas caen en las reglas comodín de empleados. Gerencia los revisa en problemas_reportados.html (abiertos/resueltos, copiar como texto). La página expone window.reportesProblemas.exportar('abierto') para que una IA los lea con el texto listo y las URL de los pantallazos.

4.4 Presencia y uso de módulos

presence-tracker.js (importado por auth-secure.js, corre en toda página autenticada):

Parámetro Valor
Latido 60 s
Ventana para "activo" 120 s desde el último input
Throttle de mousemove 1 s
Lease de líder 20 s, renovado cada 10 s
TTL del histórico (expireAt) 90 días
TTL del documento en vivo 7 días
  • Elección de líder entre pestañas con un lease en localStorage; solo la líder escribe, para no inflar conteos con varias pestañas. Las pestañas se comunican por BroadcastChannel('presence_tracker'): se pasan su último input y se cuentan entre sí.
  • Escribe presence_live/{username} con {state: 'active'|'idle', lastBeat, sessionId, page, tabs, expireAt}.
  • Escribe presence_days/{username}_{YYYY-MM-DD} con un mapa por minuto del día slots[0..1439] = {a|i: increment(1), k (input fuerte), t} y un mapa paginas[<pagina sin .html>] = {a|i: increment(1)} (minutos activos o abierta sin uso por página). Es lo que mide qué módulos se usan.
  • Nunca escribe "offline": se infiere por ausencia de latido en el visor.

track-location.js (lo importan las páginas): en cada carga, cambio de URL por pushState, popstate o hash (no replaceState, porque una página lo llamaba por cada clic) y cada 5 min con la pestaña visible, toma la posición GPS (alta precisión, timeout 12 s, acepta lecturas de hasta 30 s) y, si la precisión es ≤ 500 m, agrega un documento a dim_location con {usuario, hora_local, latitud, longitud, accuracy, url, pagina, ts}. Falla en silencio si no hay permiso de ubicación. Además de la ubicación, sirve como registro de qué página se abrió y cuándo.

Visor: reporte_actividad.html y pulso.html muestran quién está activo e inactivo.

4.5 Aviso de versión nueva (version.json y js/version-watch.js)

  • El workflow escribe version.json en cada deploy (no está en el repo).
  • version-watch.js lo pide cada 60 minutos (antes 45 s: con muchos deploys seguidos el aviso salía sin parar). Guarda la versión con la que abrió la página.
  • Si cambió: si la persona está "ocupada" (modal abierto, foco en un campo, tecla en los últimos 2 min, o ya tocó o cambió algo en la página), muestra una pastilla amarilla arriba al centro con botón "Actualizar" y no recarga sola. Si la pestaña está en segundo plano, recarga al volver. Si no hay nada abierto ni escrito, recarga sola tras una cuenta regresiva de 3 s.
  • Se carga también en páginas sin header (quizzes, hoja de servicio).

4.6 Otros helpers transversales

  • escapeHTML: definido en check-permissions.js y respaldado en load-headersecure.js para páginas sin el primero. Obligatorio para todo contenido de usuario que vaya a innerHTML.
  • nombre-empleado.js: dos campos que no se mezclan: nombrePreferido (cómo quiere que la llamen, manda en toda la app) y aliasRuta (apodo corto para el cronograma). El nombre legal completo solo en planillas, certificados y auditoría.
  • shared/telefono.js: forma canónica = la del wa_id de WhatsApp: solo dígitos, con código de país, sin "+". Quita 00 inicial; 8 dígitos → 506 + número; 10 dígitos que empiezan con 06 → antepone 5; ids de Messenger (fb_...) e Instagram no son teléfonos. Es la llave de cruce entre citas, leads, cartera y llamadas. Reemplazó 33 normalizadores distintos.
  • shared/canales.js: lista de canales de captación (ad Meta, google_ads, youtube, bing, tiktok, sitio_web, directo, messenger, saliente, empleo, referido...) con etiqueta, color, columna de gasto en pauta_spend, si entra al reparto de leads y si cuenta como lead.
  • shared/empleado.js: usernameDesdeCorreo, esActivo, esGerencia.
  • shared/moneda.js: ver 8.4.
  • sw.js: un service worker que se autodesinstala y borra cachés (quedó para limpiar uno viejo). No hay PWA ni funcionamiento sin conexión.
  • mobile-ui.js, header-compacto.js: ajustes de interfaz en celular.

5. Reglas de Firestore y Storage

firestore.rules tiene unas 2.000 líneas y 89 bloques match. Se publican con cada push a main.

5.1 Funciones base

isEmpleadoSoloDominio():
    autenticado y con correo del dominio de la empresa
    // NO prueba que trabaje acá (el registro está abierto). Solo se usa para
    // leer la propia ficha y crear el propio lookup antes de tenerlo.

isEmpleado():
    isEmpleadoSoloDominio() y exists(empleados_uid_lookup/{uid})

tieneLookup():          = isEmpleado()        // alias histórico usado en decenas de reglas
miEmpleadoId():         get(empleados_uid_lookup/{uid}).empleadoId
misPermisos():          get(empleados/{miEmpleadoId()}).permisos
miUsername():           username derivado del correo del token, en minúsculas
                        // sin leer la ficha: ahorra una lectura por escritura
esMiNombre(v):          v en minúsculas es miUsername, o el correo completo,
                        o la parte local del correo (las páginas firman de tres formas)
permiso(clave):         tieneLookup() y misPermisos().get(clave, false) == true
esGerencia():           tieneLookup() y (departamento o cargo calza ".*geren(c|t).*")

Notas de diseño:

  • exists/get sobre el mismo documento se cachea dentro de una evaluación, así que la lectura del lookup no se paga dos veces.
  • matches() exige coincidencia completa: gerenc no calza "gerente", por eso geren(c|t).
  • permiso() no aplica la herencia. Por eso las reglas solo dependen de unas pocas claves "duras" (empleados, planilla, reportes_gerenciales, administrador_bodega, liquidaciones, portal_clientes_admin, anular_monto_cita, edicion_horas, pulso, gps, gps360, gps_ventas, lealtad, logistica), y los bloques de módulos nuevos repiten la herencia en línea.

Ficha y lookup:

empleados/{empId}
  read:   isEmpleado()  o  (soloDominio y ficha.firebaseAuthUID == mi uid)
  update (autoservicio): solo la propia ficha (correo == username de la ficha)
          y solo campos: cedulaFotoFrente, cedulaFotoDorso, licenciaFotoFrente,
          licenciaFotoDorso, fotoPerfil, nombrePreferido
  create/update (administración): permiso('empleados')
          y (no es la propia ficha o esGerencia())
          y no deja una ficha ajena con mi uid (sinColgarseDeFichaAjena)
  update (quitarse accesos): permiso('empleados'), propia ficha, solo el campo permisos,
          y sin GANAR empleados, planilla ni reportes_gerenciales (quitárselos sí)
  delete: permiso('empleados') y (no propia o gerencia)
  empleados/{empId}/privado/{doc}
     read:  la propia persona, planilla, empleados, reportes_gerenciales o gerencia
     write: (empleados o gerencia) y (no propia o gerencia)

empleados_uid_lookup/{uid}
  read:   isEmpleado()  o  (soloDominio y uid == mi uid)
  create/update: solo el campo empleadoId, y además:
          (soloDominio y uid == mi uid y apuntaAMiFicha(empleadoId))
             // la ficha existe, lleva mi uid y su username es el de mi correo
          o (permiso('empleados') y uid != mi uid y lookupCoherente(uid, empleadoId))
             // la ficha destino lleva exactamente ese uid
  delete: permiso('empleados')

Estas amarras cierran dos escaladas reales que se encontraron: un técnico que apuntaba su lookup a la ficha del gerente y heredaba todos sus permisos, y un administrador que le plantaba su propio uid a la ficha de gerencia para luego apuntar su lookup ahí.

5.2 El comodín y su lista de exclusión

Las reglas de Firestore se suman: si una regla general permite algo, ninguna regla más específica lo puede quitar. Por eso el comodín no es {document=**} sino de un segmento con nombre:

match /{col}/{documento=**} {
  allow read, write: if isEmpleado() && !(col in [ ...lista de exclusión... ]);
}
  • Toda colección nueva queda cubierta automáticamente para empleados (leer y escribir).
  • Las colecciones con bloque propio tienen que estar en la lista de exclusión, si no el comodín las vuelve a abrir. La lista tiene unas 85 colecciones: empleados, empleados_uid_lookup, citas, planilla, historico_planillas, planilla_cierres, liquidaciones, whatsapp_conversations, whatsapp_messages, whatsapp_activity, whatsapp_reclamos, whatsapp_autoassign_config, stock_bodegas, movimientos_inventario, flota_tracks, flota_gps_estado, flota_gps_equipos, velocidad_diaria, dim_location, work_sessions, vacaciones, snapshots_ventas, encuestas, encuestas_citas, reportes_cliente, wa_clicks, garantias, tickets_rojo, reportes_publicos, portal_cuentas, portal_accesos, portal_meta, tickets_sucursal, wa_equipo_*, bot_ventas_*, publicidad_*, cumplimiento_*, videos_*, recuperador_cartera*, satisfaccion_*, certificados_emitidos, citas_monto_log, empleados_baja_log, _rate_limits, clientes_bloqueo, roles_ventas, politica_promesas, promesas_bloqueadas y otras.
  • Se descubrió que algunas tenían bloque propio pero faltaban en la lista; se corrigió y se dejó comentado.

5.3 Patrones recurrentes

Patrón Cómo se escribe Ejemplos
Solo servidor escribe allow write: if false; y la lectura acotada. Las funciones usan el Admin SDK, que ignora las reglas. citas_monto_log, empleados_baja_log, promesas_bloqueadas, bot_ventas_leads, satisfaccion_*, wa_equipo_cuentas, movimientos_inventario (create/delete)
Solo servidor lee y escribe allow read, write: if false; _rate_limits, web_leads_limites, simulador_*, config_simulador, portal_cuentas, portal_accesos, portal_meta
Campos protegidos request.resource.data.diff(resource.data).affectedKeys().hasAny([...]) o comparar el valor antes y después Monto de una cita, firma de creación, campos de conteo del CRM, calidad de venta
Lista blanca de campos affectedKeys().hasOnly([...]) Autoservicio de la ficha, cambios de estado en el recuperador de cartera
Firma propia El campo autor tiene que ser miUsername() o esMiNombre() creado_por de citas, solicitante de reclamos, emitidoPor de certificados, usuario de flota_tracks
Hora del servidor d.emitidoEn == request.time Certificados, fecha_creacion de citas
Solo agregar nuevaLista.hasAll(listaAnterior) Historial de ediciones de la cita
Público acotado Sin login, pero con lista exacta de campos, topes de tamaño y una sola vez Respuesta de encuestas (máx. 60 respuestas, user agent ≤ 400 caracteres), wa_clicks (4 campos con largo máximo), formulario de garantía
get público, list interno allow get: if true; allow list: if isEmpleado(); El id es un token o serie no adivinable, así no se puede bajar el padrón. certificados_emitidos, garantias, reportes_publicos
Ventana de tiempo resource.data.inicio_gmt6 + duration.value(18, 'h') > request.time Cobro de una cita el mismo día; ajuste de una venta dentro de 24 h de creada
Herencia en línea misPermisos().get('nueva', misPermisos().get('reportes_gerenciales', false)) == true videos_reuniones, promesas_bloqueadas, coordinacion_respuestas, recuperador_cartera, WhatsApp de empresa
Campo directo, no get() con defecto, para filtros de lectura Con get('soloDe','') una consulta sin filtro pasaba y traía datos privados; con acceso directo la consulta tiene que filtrar o falla. Teléfono privado y grupos en wa_equipo_cuentas
Subcolecciones nombradas match /{sub}/{subId} y no match /{sub=**} Dentro de citas: el comodín recursivo también calza la ruta vacía (el propio documento) y anulaba todas las protecciones. Lo detectó el emulador, no la revisión a ojo.

5.4 Colecciones sensibles: quién lee y quién escribe

Colección Lee Escribe
empleados empleados (y la propia ficha antes del lookup) empleados sobre otros; cada quien sus fotos y nombre preferido; gerencia también la propia
empleados/{id}/privado la propia persona, planilla, empleados, reportes, gerencia empleados o gerencia, nunca la propia (salvo gerencia)
empleados_uid_lookup empleados; la propia fila la propia persona (apuntando a su ficha) o empleados (coherente)
planilla, historico_planillas planilla o reportes_gerenciales igual
planilla_cierres planilla, reportes o liquidaciones igual
liquidaciones liquidaciones, planilla o reportes igual
citas empleados crear: empleado con firma propia válida; editar: empleado sin tocar monto, precio final fuera del día, firma ni calidad de venta (gerencia sí; anular a ₡0 con anular_monto_cita antes de prestarse); borrar: nadie
citas/{id}/extras/historial empleados crear sí; editar solo agregando; borrar nadie
citas_monto_log, empleados_baja_log reportes o gerencia nadie (solo servidor)
whatsapp_conversations empleados reglas de dueño del lead (assignedTo): tomar uno libre, soltar o ceder el propio, no asignar a usuarios bot salvo gerencia, no mover de columna un lead ajeno, campos de conteo y de llamada obligatoria solo gerencia o firmados por uno mismo; borrar solo gerencia
whatsapp_messages empleados nadie (solo servidor)
whatsapp_reclamos empleados crear a nombre propio en pendiente; resolver gerencia
stock_bodegas empleados administrador_bodega o gerencia (el flujo normal pasa por la función movimientoInventario)
movimientos_inventario empleados crear y borrar nadie (solo la función); anotar campos de revisión con administrador_bodega
flota_tracks reportes; el propio técnico; gps, gps360 o gps_ventas solo si fuente == 'gps' (vehículo, no persona) el propio técnico, id validado contra su username
dim_location reportes o gerencia crear empleado; editar o borrar nadie
work_sessions empleados crear propia; editar ajenas con edicion_horas, pulso o gerencia; borrar igual
vacaciones empleados crear propia en pendiente; aprobar quien gestiona vacaciones
snapshots_ventas reportes o gerencia nadie
roles_ventas empleados gerencia
encuestas, encuestas_citas empleados empleados; el cliente responde una sola vez con campos acotados
certificados_emitidos get público; list empleados crear empleado con esquema fijo; nadie edita ni borra
garantias get público (token); list empleados el cliente envía su formulario una vez; gestiona quien tiene garantias; borrar nadie
portal_cuentas, portal_accesos, portal_meta, tickets_sucursal nadie desde el navegador (todo por funciones) nadie
wa_equipo_cuentas y subcolecciones según visibilidad del teléfono (privado, del equipo, de gerencia, grupos solo del asignado) nadie (sube el agente con credenciales de servidor)
wa_equipo_salida gerencia o quien envió crear pendiente a nombre propio sobre un teléfono visible; nunca a grupos ajenos
bot_ventas_config, _tarifario, _ejemplos empleados gerencia
bot_ventas_leads, _propuestas, _log, _metricas permiso del bot (propuestas también logística) nadie
publicidad_*, cumplimiento_* acceso del módulo acceso del módulo
_rate_limits, config_simulador nadie nadie

5.5 Storage

isEmpleado(): correo del dominio y firestore.exists(empleados_uid_lookup/{uid})

/commons/**                         lectura pública, sin escritura
/publicidad/**, /cumplimiento/**    empleados
/videos_publicitarios/**            leer empleados, escribir nadie (se sube por consola/servidor)
/videos_reuniones/**                leer con permiso videos_reuniones (o Reportes), escribir nadie
/garantias_publicas/{token}/**      leer público; crear sin login: imagen < 10 MB o video < 60 MB;
                                    editar o borrar empleados
/{archivo} y /{carpeta}/**          empleados, salvo carpetas wa_equipo y videos_reuniones;
                                    al crear: < 60 MB y tipo permitido (imagen, video, audio, PDF,
                                    Office, texto, zip, json, octet-stream)
/wa_equipo/{numero}/salida/{archivo} empleados (adjuntos que luego manda el agente)

El bucket tiene una configuración CORS que se aplica en cada deploy para que el navegador pueda leer fotos y meterlas en un PDF o imagen; CORS no da acceso, las reglas siguen mandando.

5.6 Realtime Database

Se usa solo para la posición en vivo de la flota (flota/live/{usuario}): cada técnico escribe solo su nodo con validación de tipos (lat, lng, ts, usuario, estado, bat, vel, etc., cualquier otro campo rechazado); leen todos solo los usuarios marcados en flota/admins.

5.7 Cómo se prueban

  • Compilar no es estar cerrado. Una regla que compila puede estar abierta de par en par (el caso del comodín recursivo dentro de citas).
  • Se prueban con @firebase/rules-unit-testing contra el emulador de Firestore (firebase emulators:start --only firestore --project demo-x, variable FIRESTORE_EMULATOR_HOST=127.0.0.1:8080). Al 18-oct-2026 eran 144 casos.
  • Tres grupos de casos: lo que el equipo hace a diario (tiene que pasar), intentos de abuso (tienen que fallar), y documentos raros (sin el campo, con el campo en 0, con decimales, y borrando el campo con deleteField() para esquivar la comparación).
  • El emulador necesita Java; se puede usar un JDK portable sin instalar nada.
  • El archivo de pruebas no está en el repositorio (se arma en un directorio temporal).
  • Las funciones tienen pruebas unitarias propias en functions/test-bot-ventas/*.test.js (bot de ventas, envío de WhatsApp, geografía, asignación) y inventario.js exporta sus funciones internas para probarlas sin emulador.

6. Cloud Functions

6.1 Organización

  • Firebase Functions v2 (firebase-functions/v2/https, /firestore, /scheduler), Node 22, región us-central1, Admin SDK inicializado una vez.
  • functions/index.js concentra unas 9.300 líneas y 169 exports. Los dominios nuevos viven en módulos propios y se cablean de dos formas:
  • Módulo que exporta funciones ya construidas: exports.portalAdminLeer = require('./portal-admin').portalAdminLeer.
  • Módulo fábrica: require('./web-leads').construir({ onRequest, onSchedule, admin, origenes, secretos, enviarPlantilla }) devuelve las funciones, inyectándoles dependencias de index.js (secretos, helpers de envío, orígenes CORS). Así el módulo no conoce index.js y se puede probar.
  • Triggers livianos con require perezoso dentro del handler: async () => { await require('./velocidad-resumen').barrer(admin.firestore(), admin) } (el arranque en frío no carga módulos que la función no usa).
  • Secretos por defineSecret (Secret Manager): token y número de WhatsApp Cloud API, secreto de la app y token de verificación del webhook, tokens de Meta Marketing y de la página, clave de Anthropic, clave de ElevenLabs, credenciales de Twilio, clave de Esri, claves compartidas para el feed de Google Ads y para la ingesta de gasto. Cada función declara solo los que usa.
  • Opciones por función: memory (256 MiB a 2 GiB), timeoutSeconds (10 a 540), maxInstances (3 a 20 donde importa no saturar), retry en triggers donde un reintento es seguro, cors con lista de orígenes permitidos (ALLOWED_ORIGINS para el sistema interno, SITE_CORS_ORIGINS para el sitio público).
  • Dependencias: firebase-admin, firebase-functions, node-fetch, form-data, ffmpeg-static (convertir audios de WhatsApp a OGG/Opus), puppeteer-core + @sparticuz/chromium (generar imágenes de citas y mapas en el servidor).

6.2 Helpers comunes

Helper Archivo Qué hace
verifyAuth(req) index.js Lee Authorization: Bearer <idToken> y lo verifica con admin.auth().verifyIdToken. Acepta cualquier cuenta del proyecto, incluida una anónima: no usar solo.
verifyEmpleado(req) index.js verifyAuth + correo del dominio + conFicha(). Devuelve el token completado con username, empleadoId, permisos (con herencia), esGerencia, departamento, o null.
conFicha, fichaDeToken auth-empleado.js Resuelve lookup → ficha (respaldo solo por firebaseAuthUID, nunca por id = username, para que una cuenta inventada no calce con una ficha). Caché en memoria por uid: 5 min positivo, 60 s negativo.
exigirPermiso(authUser, [claves]) auth-empleado.js Verdadero si es gerencia o tiene alguna de las claves.
esGerenciaBackend(db, authUser) index.js Usa authUser.esGerencia si ya viene resuelto.
fechaDiaCR(d) index.js Día calendario de Costa Rica: new Date(t - 6h).toISOString().slice(0,10) (UTC-6 fijo, sin horario de verano).
sanitizeForPrompt(val) index.js Recorta y quita saltos de línea antes de meter datos en un prompt de IA (contra inyección de prompt): textos a 500 caracteres, listas a 20, objetos a 30 claves, profundidad 3.
Rate limit index.js _rate_limits/{tipo}_{uid}_{ventana} con transacción: 60 mensajes de WhatsApp por minuto por usuario (429 si se pasa); límite diario de llamadas. Campo expira para la política TTL.
wa-envio.js módulo Envío de servidor a la API de WhatsApp Cloud: escribe el mismo documento saliente en whatsapp_messages que whatsappSend. Idempotencia por docId: crea el documento en estado: 'enviando' antes de llamar a Meta (si ya existe, devuelve repetido) y escribe el wamid apenas Meta responde. Clasifica errores por código de Meta: 131048 (spam) apaga el bot, 131056 pausa el teléfono 60 s, 132015/132016 marcan la plantilla pausada. Solo reintenta lo que con certeza no llegó a Meta (429, 5xx, conexión rechazada); un timeout a mitad de camino queda incierto y no se reintenta, para no mandar dos veces.
ia-saldo.js módulo Si la IA responde falta de crédito, devuelve 503 ia_sin_saldo y avisa una vez al día.
shared/telefono.js, shared/canales.js, shared/empleado.js functions/shared Espejos CommonJS de los de public/shared/.
Cola de envíos de teléfonos de empresa wa_equipo_salida El navegador crea un documento pendiente; un agente externo (no es Cloud Function) con sesión de WhatsApp Web lo toma, lo envía y actualiza el estado. Los adjuntos van a Storage wa_equipo/{numero}/salida/.

Patrón de un endpoint HTTP:

exports.algo = onRequest({ cors: ORIGENES, region, secrets: [...], timeoutSeconds }, async (req, res) => {
  si req.method != 'POST': 405
  authUser = await verifyEmpleado(req)
  si !authUser: 401
  si !exigirPermiso(authUser, ['clave_del_modulo']): 403 { error: 'Sin permiso de X' }
  validar body
  leer/escribir con admin.firestore() (ignora reglas), en transacción si toca saldos o montos
  dejar rastro (log o historial)
  res.json({ ok: true, ... })
})

En el navegador:

token = await firebaseAuth.currentUser.getIdToken()
fetch('https://<region>-<proyecto>.cloudfunctions.net/<nombre>',
      { method: 'POST', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
        body: JSON.stringify(datos) })

6.3 Inventario de funciones HTTP y callable

Identidad y empleados

Función Qué hace
mintWebToken (callable) Custom token para que la app Android entre a las páginas web con la misma sesión.
crearEmpleadoAuth Crea la cuenta de Auth de un empleado (exige empleados).

IA de texto (Anthropic)

Función Qué hace
mejorarTexto Corrige ortografía y redacción de un dictado o comentario (acepta nombres de productos para corregirlos bien).
pulirComentarioReporte Pule el comentario del técnico en el reporte de servicio.
resumirComentariosCitas Resume comentarios largos de citas para el buscador.
generarComentarioConsolidado Consolida varios comentarios en uno.
detectarGarantias Analiza mensajes de clientes ya atendidos para detectar reclamos de garantía.
mejorarConCerebro, revisarConsistencia, proponerConInternet, chatCerebro, sugerirPreguntas Base de conocimiento interna ("Cerebro", conocimiento.html): mejorar entradas, revisar contradicciones, proponer contenido, chatear, sugerir preguntas.
ecobotChat, ecobotActualizarFAQ Asistente interno EcoBot y regeneración de sus preguntas frecuentes (solo gerencia).
whatsappAssistant, whatsappEvaluate Sugerencia de respuesta y evaluación de una conversación del CRM.
whatsappAtribucion Atribuye el canal de un lead leyendo la conversación (texto saneado, últimos 60 mensajes).
perfilClienteIA Perfil de un cliente a partir de su historial.
sugerirCamposRecuperador Sugiere datos para el recuperador de cartera.
revisarJustificacionSinOptimus Juzga si la justificación escrita por un vendedor para no usar el optimizador de rutas es una restricción real.
evaluarEscrituraVendedores Evalúa la escritura de cada vendedor (exige Reportes; reporte diario cacheado 12 h para no repagar el modelo).

WhatsApp Cloud API (línea oficial) y CRM

Función Qué hace
whatsappWebhook Recibe mensajes, estados y llamadas de Meta. Idempotente ante reintentos (no reescribe un mensaje ya guardado ni recuenta), normaliza el teléfono, actualiza la conversación (último mensaje solo avanza), extiende la ventana de 24 h (72 h si vino de anuncio), detecta código de visita del sitio.
whatsappSend Envío desde el CRM: texto, plantillas, media; rate limit 60/min; aplica la política de promesas (bloquea mensajes que prometen garantía o erradicación); firma como el usuario del token.
whatsappMarkRead, whatsappDeleteMessage Marcar leído; borrado lógico en el CRM (la API no borra mensajes enviados).
whatsappCallAction, whatsappCallPermission, whatsappPedirPermisoLlamada Llamadas de WhatsApp: acción, estado del permiso (copia local o consulta a Meta), pedir permiso al cliente.
crearPlantillaWA, estadisticasPlantillas Crear plantillas en Meta; estadísticas por plantilla (barrido caro de unas 65 mil lecturas, cacheado en plantillas_stats).
reasignarLeadFrio, backfillLeadFrio, aprobarReclamoAutomatico Reasignar un lead que quedó frío (verifica requisitos con Admin SDK porque el navegador no puede tocar assignedTo ajeno), su backfill, y reclamos de lead aprobados automáticamente.
preverReparto, repartirLeads, reconstruirHistorialReparto Reparto de leads entre vendedores (vista previa, ejecución, reconstrucción).
exportCustomerPhones Exporta teléfonos de clientes para audiencias de anuncios (gerencia).

Telefonía (Twilio)

Función Qué hace
telTwilioToken Token para que el navegador del vendedor llame.
telTwilioVoz Webhook de voz (autenticado por firma de Twilio, no por token).
telTwilioEstado Registra el resultado de la llamada.

Publicidad y atribución

Función Qué hace
syncMetaAdMapping, syncMetaAdSpend Mapeo de anuncios a campañas y gasto de Meta (manual; tienen versión programada).
gadsClick, gadsFeed Registro de clics de Google Ads (con create, no merge, para que nadie pise un gclid) y CSV de conversiones offline que Google Ads importa.
webClick, webLead Registro de visitas del sitio público con código corto y captura de leads de formularios y chat del sitio.
pautaGoogleIngest, pautaSpendAhora Ingesta del gasto de Google (empujado por un script de Google Ads con clave compartida) y recálculo manual de pauta_spend.
resumenSitioAhora Recalcula el resumen de visitas del sitio.
telefonoVentas Qué teléfono de ventas mostrar en el sitio según el turno.
rollupVentasMensuales, snapshotVentasNow Resumen mensual de ventas (recalcula solo la cola reciente, TTL 5 min) y foto de ventas manual.
reconcileClientesNow Reconciliación de clientes bajo demanda (gerencia).

Clientes, lealtad, cartera

Función Qué hace
lealtadDatos Tres años de citas resumidos para lealtad.html (exige lealtad).
reporteCliente Reporte de un cliente sin login: valida teléfono + clave en servidor; la clave nunca vuelve al navegador.
getLeadsB2bData Sirve la base de leads B2B autenticada (antes era un archivo público).
recuperadorCarteraRecalcular, recuperadorCarteraTomar, recuperadorCarteraSoltar Clientes con 6 o más meses sin servicio: recalcular, tomar o soltar.
miniTacticoDatos Datos del tablero de ventas propio: lee el mes una vez con los campos justos, cachea minutos en tactico_ventas_cache y a cada vendedor le devuelve solo sus citas.

Portal de clientes (otro proyecto de Firebase sirve el portal; estas funciones son su backend)

portalEnviarCodigo (código de acceso por WhatsApp, autenticado con clave compartida porque el portal es otro proyecto), portalAdminLeer, portalAdminGuardar, portalAdminListar, portalAdminSimular (ver el portal como un teléfono), portalAdminCuenta, portalAdminSellarIdentificadores, portalAdminArbol (cliente → sedes → reportes), portalAdminEjecutivos, portalAdminSede, portalAdminMachote (servicio escrito sin agendar), portalAdminCatalogo, portalAdminTranscribir (audios de reportes a texto, una sola vez), portalAdminEstado, portalAdminFusionar, portalAdminFusionarSede, portalAdminVincularPlaces, portalAdminCrearSede, portalAdminBorrarSede, portalAdminBloqueo, portalAdminCrearTicket, portalAdminCrearRefuerzo, portalAdminRefuerzoEstado, portalAdminEditarRefuerzo, portalTicketGuardar/Listar/Estado/Borrar (tickets con visibilidad interno, gerencia o cliente filtrada en servidor), clienteFicha, clienteCartera, clienteCuentas, portalAdminTickets, portalAdminTicketEstado, portalAdminSyncEspejoCartera, portalAdminBootstrapSoT, portalAdminVerificarEspejo, portalAdminBorrarCitasDemo, portalPushCuenta, portalSolicitudesListar, portalSolicitudConvertir, portalSolicitudDescartar.

Operación, citas e inventario

Función Qué hace
cambiarMontoCita Cambia el monto de una cita con las mismas reglas que Firestore, dejando historial y citas_monto_log en una transacción. Body {citaId, monto, motivo}.
enviarCitaAVentas, ventasPantallazo Imagen de la cita más mapa de la ruta del día al grupo de Ventas; vista previa y aviso de cita movida.
movimientoInventario Única vía de escritura de stock_bodegas y movimientos_inventario (gasto, compra, transferencia, ajuste y reversión en una transacción).
auditoriaGuachimanAviso Envía el resumen de una auditoría de bodega.
waEquipoLeadsNow, waCoordinacionCitasNow, confirmacionVencidaNow, recuperadorCarteraRecalcular Versiones manuales (gerencia) de tareas programadas.
simuladorIniciar, simuladorCalificar Simulador de campo por voz para capacitación (tope diario por persona).
previewVentasBot, asignarLeadBot, botVentasAdmin Bot de ventas: vista previa, pasar o quitar un lead al bot, panel de administración (única vía de escritura de sus colecciones).

6.4 Triggers de Firestore

Función Evento Colección Qué hace
descargarMediaWA creado whatsapp_messages/{msgId} Baja la media de Meta a Storage (con reintento; relee estado para no duplicar).
botVentasOnInbound creado whatsapp_messages/{msgId} El bot de ventas contesta (sin reintento: reintentar contestaría dos veces).
botVentasOnStatus actualizado whatsapp_messages/{msgId} Si un mensaje del bot falla, pasa el lead a una persona.
recuperadorCarteraOnMensaje creado whatsapp_messages/{msgId} Actualiza el cliente en la cartera de recuperación al enviar o recibir.
estamparPrimerAsignado escrito whatsapp_conversations/{tel} Congela el primer vendedor asignado y su hora.
citaOverlapCheck creado citas/{citaId} Detecta choques de horario en servidor (con día operacional).
metaCapiOnCita escrito citas/{citaId} Envía la compra a la API de conversiones de Meta.
stampClienteOnCita escrito citas/{citaId} Marca al cliente como cliente al crear, editar o borrar citas.
llamGCompraOnCita escrito citas/{citaId} Estampa que un lead con llamada obligatoria agendó.
recuperadorCarteraOnCita escrito citas/{citaId} Saca al cliente de la cartera de recuperación y lo marca recuperado.
garantiaRefuerzoOnCita escrito citas/{citaId} Vincula una cita de refuerzo con su garantía abierta; además avisos de cancelación y tickets de reporte en rojo.
portalPushAlCerrarReporte, portalPushAlAgendar escrito citas/{citaId} Empuja reportes y visitas al portal de clientes.
ventasAutoEnviar creado citas/{citaId} A los 3 minutos de creada, manda la imagen de la cita nueva al grupo de Ventas (2 GiB, hasta 540 s).
garantiaAvisoRespuesta actualizado garantias/{id} Aviso por WhatsApp cuando el cliente envía el formulario.
alertasGpsOnTrack escrito flota_tracks/{trackId} Evalúa cada lote de puntos GPS (velocidad, paradas fuera de ruta).
alertasGpsEnviar, alertasGpsReenviar creado / actualizado alertas_gps/{alertaId} Arma y manda la alerta (imagen y mensaje).
empleadoBajaOnWrite escrito empleados/{id} Deshabilita o rehabilita la cuenta y el lookup.
telefonoVentasOnEmpleado escrito empleados/{id} Recalcula el teléfono de ventas por turno.
telefonoVentasOnRol escrito roles_ventas/{lunes} Igual, al cambiar los roles de la semana.
revisarReporteRojo escrito reportes_publicos/{token} IA revisa la nota del técnico antes de que la vea el cliente (groserías, juicios, datos de terceros, promesas prohibidas).
espejoPortalCuenta, espejoPortalSede, espejoPortalReporte, espejoPortalVisita, espejoPortalAcceso escrito portal_cuentas/{cuenta} y subcolecciones sedes, reportes, visitas; portal_accesos/{tel} Espejan los datos al proyecto del portal de clientes.

6.5 Funciones programadas

Todas con zona horaria America/Costa_Rica (UTC-6, sin horario de verano).

Función Horario Qué hace
sincronizarMessenger cada 1 minuto Trae conversaciones de Facebook Messenger al CRM.
alertasGpsBarrido cada 5 minutos Cierra episodios de carros que dejaron de reportar y revisa rutas terminadas.
alertasLlegadas */5 5-22 * * * (cada 5 min de 5:00 a 22:55) Alerta de llegada tardía de quien debía iniciar turno.
ticketsRojoVerificar cada 10 minutos Cierra tickets de citas en rojo cuyo mensaje al cliente ya salió.
seguimientoWebLeads cada 15 minutos Seguimiento de leads del sitio web.
botVentasSeguimientos */15 * * * * Seguimientos del bot a leads callados, vencimiento de propuestas, handoffs pendientes.
coordinacionRespuestas */15 * * * * Tiempo y porcentaje de respuesta a etiquetas de técnicos en el grupo de coordinación.
waDesconectadosAviso */15 6-22 * * * Avisa a quien tiene asignado un teléfono de empresa desconectado (en su horario).
tiemposRespuestaRecientes */30 * * * * Tiempos de respuesta humana de hoy y ayer.
waEquipoLeadsRecientes */30 * * * * Leads nuevos en teléfonos de empresa (si eran nuevos, si se respondió, si compraron).
pautaSpendHorario 5 * * * * (minuto 5 de cada hora) Gasto de Meta por API a pauta_spend.
waCoordinacionCitasHoraria 20 * * * * Anota por qué WhatsApp se coordina cada cita de hoy a +7 días.
velocidadResumenDiario cada 2 horas Resumen de velocidad por empleado (hoy y ayer).
resumenSitio 15 */3 * * * Resumen de visitas del sitio cada 3 horas.
ventasSync cada 6 horas Cruza citas con monto y conversaciones → ventas_atribuidas; envía compras a Meta.
recuperadorCarteraNocturno 20 4 * * * (4:20) Recalcula la cartera de recuperación.
reconcileClientesScheduled 30 4 * * * (4:30) Reconciliación nocturna de analitica_clientes (1 GiB, 540 s).
satisfaccionClientes 30 4 * * * (4:30) Clasifica con IA lo que escribieron los clientes después de su cita (15 días) y rehace el resumen semanal.
actualizarAtributosCrmScheduled 45 4 * * * (4:45) Atributos del CRM, incremental (solo mensajes nuevos).
telefonoVentasDiario 0 5 * * * (5:00) Teléfono de ventas del día.
syncMetaAdMappingScheduled 0 6 * * * Mapeo de anuncios de Meta (2 reintentos).
syncMetaAdSpendScheduled 0 7 * * * Gasto de Meta del día anterior.
garantiasResumenDiario 0 8 * * 1-6 (lunes a sábado 8:00) Resumen de garantías que esperan decisión.
weeklyRoiReport 0 8 * * 1 (lunes 8:00) ROI de 7 días a meta_roi_reports/{YYYY-WXX} y alerta de costo por lead.
confirmacionAviso12 0 12 * * * Aviso de citas de mañana sin confirmar.
confirmacionAviso14 0 14 * * * Segundo aviso.
confirmacionVencida1530 30 15 * * * A las 15:30 marca las no confirmadas como vencidas (no comisionables).
snapshotVentasDiario 45 23 * * * (23:45) Foto del día de ventas, después del cierre y antes del siguiente día operacional.

Observación de diseño: las tareas nocturnas se escalonan entre 4:20 y 5:00, antes de que empiece el día operacional a las 5:00.

7. Colecciones de Firestore

Inventario obtenido de las llamadas collection(...) y doc(...) en public/ y functions/ (más de 220 nombres). Agrupado por dominio.

7.1 Personas y tiempo

Colección Propósito y campos clave
empleados Ficha: username, primerNombre, primerApellido, nombrePreferido, aliasRuta, departamento, cargo, estado, activo, permisos{}, firebaseAuthUID, firebaseAuthEmail, fotoPerfil, fotos de cédula y licencia, tipoPago, metas y porcentajes de comisión. Subcolección privado/datos con salario, banco, cédula y tarifas.
empleados_uid_lookup {uid} → {empleadoId}. Base de la identidad en reglas.
empleados_baja_log Bitácora de bajas y reactivaciones.
work_sessions Turnos de trabajo: username, firebaseAuthUID, status (working/completed), startTime, endTime, lastActivity, modo, closedBy.
work_sessions_logs Ediciones de turnos.
vacaciones Solicitudes: empleadoId, estado (pendiente, aprobada, rechazada), fechas, quién aprobó.
teletrabajo Días de teletrabajo.
planilla, historico_planillas, planilla_cierres, planilla_alias, liquidaciones Planilla quincenal o mensual, cortes históricos, cierre por persona y mes, liquidaciones.
presence_live, presence_days, presence_diag Presencia en vivo, minutos por día y por página, diagnóstico.
dim_location Posición GPS por carga de página.
preferencias_usuario Tema de pantalla por uid.
politicas, politicas_acuses Políticas internas y quién firmó el acuse.
Capacitación: quizzes_entrenamiento, certificados_emitidos, impresiones_capacitacion, config_simulador, simulador_inicios, simulador_conversaciones Resultados de exámenes, certificados con QR público, hojas impresas del expediente, simulador por voz.
actividades, actividades_catalogo Pedidos para actividades de la empresa.
alertas_llegadas, alertas_confirmacion Alertas de llegada tardía y de confirmación.

7.2 Citas y operación de rutas

Colección Propósito y campos clave
citas La entidad central. telefono, telefono_normalizado, inicio_gmt6 (Timestamp), duracion_minutos, fecha_dia (YYYY-MM-DD del día operacional), fecha_mes (YYYY-MM), creado_por, fecha_creacion, whatsapp_coordinacion, y metadata{ cliente_nombre, ruta ("Ruta 1".."Ruta 20"), monto, precio_final_rutero, servicios[{id,cantidad,precio,comentarios}], productos[], equipo[] (nombres de herramientas), vendedores[] (usernames), certificados, reporte, historial (extracto), ultima_edicion, venta_no_calidad, confirmacion_vencida }.
citas/{id}/extras/historial Historial completo de ediciones, solo agregar.
citas_monto_log Cada cambio de monto.
ruteador, ruteador_dias, ruteador_historial Asignación diaria: fecha, ruta, tecnicos[], vehiculos[]; cambios.
servicios Catálogo: nombre, precio, productosAsociados[].
encuestas, encuestas_citas, encuestas_id, justificaciones_sin_encuesta Encuestas post servicio por cita, ids reservados, justificaciones.
reportes_cliente, reportes_publicos, dim_reporte Reportes de servicio para el cliente (por teléfono con clave, o por token público).
tickets_rojo, config_tickets_rojo Citas con reporte grave que abren ticket al vendedor.
garantias Solicitudes de garantía por token.
sucursales, clientes, dim_cliente, contactos, ubicaciones_frecuentes, cartera_telefonos Clientes, sedes, ubicaciones, espejo de teléfonos de cartera.
estaciones_qr, centinelas Estaciones de cebo con QR y su monitoreo.
movimientos_ajenos, ventas_movidas, ventas_envios Movimientos de citas de otro vendedor, citas movidas, envíos de imágenes al grupo de ventas.
cotizaciones, cotizaciones_meta Hojas cotizadoras con fotos.
confirmacion_no_necesaria, clientes_bloqueo Teléfonos que no requieren confirmación; clientes bloqueados (solo servidor escribe).
parametros_globales, configuracion, canales, equipo Parámetros de la agenda y catálogos auxiliares.

7.3 Flota y GPS

Colección Propósito
flota_tracks Recorridos: un documento por usuario, día y bloque de 6 horas ({usuario}_{YYYY-MM-DD}_b{0-3}) para no pasar el tope de 1 MB por documento. Campo fuente distingue vehículo (gps) de teléfono.
flota_gps_estado, flota_gps_equipos Estado y alta de los equipos GPS de los vehículos.
vehiculos Vehículos.
alertas_gps, alertas_gps_estado, alertas_gps_cierres Alertas de velocidad y paradas, episodios y cierres.
velocidad_diaria Resumen por empleado y día.
atlas_zonas, residenciales_geofences Zonas y geocercas.

7.4 Inventario

Colección Propósito
bodegas nombre, tipo (principal, especial, rutero, herramientas, apv), empleadoId.
productos nombre, precioUnitario, activo, categoria, unidad.
stock_bodegas bodegaId, productoId o herramientaId, stockActual.
movimientos_inventario tipo, origen, destino, cantidad, esPedidoEspecial, marcas de reversión y revisión.
herramientas_alisto, herramientas, herramientas_catalogo, herramientas_asignaciones, herramientas_reportes Herramientas con cantidad fija por empresa, asignaciones y reportes.
cuentas_gasto, gastos, proveedores, ordenes_compra, ordenes_reposicion, saldos_minimos, catalogo_config, contadores Contabilidad de gasto, compras, mínimos por bodega.
auditorias_bodega, auditorias_bodega_estado, auditorias_sesiones, auditorias_comentarios, rotulos_impresiones Auditoría visual de bodega.
solicitudes_dotacion, verificaciones_dotacion Dotación de equipo de protección.

7.5 CRM, WhatsApp y ventas

Colección Propósito y campos clave
whatsapp_conversations Id = teléfono canónico. phone, whatsappName, assignedTo, assignedAt, primerAsignado, embudoVendedor (columna), boardCol, source (canal), sourceAt, clienteAlEntrar, esCliente, esClienteManual, lastMessage, lastMessageAt, lastMessageDirection, lastInboundAt, unreadCount, archived, windowExpires, windowOrigin, campos de llamada obligatoria (llamG*).
whatsapp_messages Id = wamid. conversationPhone, from, to, type, body, mediaUrl, mediaId, direction (inbound/outbound), status, timestamp, wamid, contactName, bot.
whatsapp_activity Bitácora de acciones sobre leads.
whatsapp_templates, whatsapp_template_tests, plantillas_stats Plantillas y sus estadísticas.
whatsapp_autoassign_config, reparto_nivelacion, reasignaciones_turno, roles_ventas, telefono_ventas Configuración del reparto de leads, roles semanales de ventas, teléfono por turno.
whatsapp_calls, whatsapp_call_permissions, llamadas_tel, llamadas_documentadas Llamadas y su documentación.
whatsapp_reclamos, whatsapp_unavailable, whatsapp_funnels Reclamos de leads, números no disponibles, embudos.
politica_promesas, promesas_bloqueadas Aceptación de la política de promesas y mensajes bloqueados.
wa_equipo_cuentas/{numero}/chats/{chatId}/mensajes, wa_equipo_salida, wa_equipo_accesos, wa_equipo_leads, wa_equipo_leads_dia, wa_equipo_config Teléfonos de empresa (WhatsApp Web vía agente externo): chats espejados, cola de salida, auditoría, leads, configuración (interruptores de avisos).
bot_ventas_* (config, tarifario, ejemplos, leads, propuestas, log, metricas, previews) Vendedores virtuales con IA.
analitica_clientes, crm_atributos, crm_meta, crm_cohorts Analítica por cliente (primera cita, recurrencia), atributos para automatizaciones.
recuperador_cartera, recuperador_cartera_meta, recuperador Clientes a recuperar.
notas_mentales Recordatorios internos por usuario.
coordinacion_respuestas, tiempos_respuesta Métricas de respuesta.
satisfaccion_citas, satisfaccion_semanas, satisfaccion_transcripciones Satisfacción post servicio.
leads_b2b_crm, leadsales_conversations, raspa_masa_log Prospección B2B y recontacto masivo.

7.6 Publicidad, sitio web y finanzas

Colección Propósito
pauta_spend Gasto diario por canal (columnas meta_crc, google_crc, youtube_crc, bing_crc, tiktok_crc).
pauta_google_raw, meta_ad_spend, meta_ad_mapping, meta_roi_reports Datos crudos y mapeos de anuncios.
ventas_atribuidas, ventas_mensuales, snapshots_ventas, meta_ventas_mes Atribución de ventas a canal, resumen mensual, fotos diarias, metas.
gads_clicks, web_clicks, web_resumen, web_experimentos, web_leads_config, web_leads_limites, wa_clicks Clics y visitas del sitio público, experimentos, límites del formulario.
publicidad_temas, publicidad_tomas, publicidad_videos, videos_publicitarios_series, videos_publicitarios_anuncios, videos_reuniones Banco de tomas y videos.
cxc_reg, cxc_config Cuentas por cobrar.
splitwise_gastos, splitwise_grupos Gastos compartidos.

7.7 Portal de clientes, soporte y sistema

Colección Propósito
portal_cuentas (+ sedes, reportes, visitas), portal_accesos, portal_meta, portal_cuentas_datos, portal_solicitudes, portal_tickets, tickets_sucursal, portal_admin_log, portal_codigos_enviados Fuente de verdad del portal de clientes, solo por funciones.
tickets, solicitudes_reportes Tickets internos entre empleados.
reportes_problemas Reportes del botón del bicho.
cumplimiento_secciones, cumplimiento_items, cumplimiento_evidencias, cumplimiento_msds Cumplimiento sanitario con evidencias.
conocimiento, chatbot_preguntas_frecuentes, prohibiciones_ecobot, registro_conversaciones_ecobot, registro_conversaciones_conocimiento Base de conocimiento y asistentes.
diagnostico_lecturas_firestore, diagnostico_login Diagnóstico de lecturas y de fallos de login.
_rate_limits Contadores de rate limit con TTL.
ia_sin_saldo_avisos, messenger_sync, cache_lealtad, tactico_ventas_cache, reportes_desempeno_crm, reportes_eficiencia, reportes_metricas_ventas, reportes_escritura Cachés de cálculos caros y estado de sincronizaciones.

8. Patrones de interfaz, fechas y moneda

8.1 Componentes y librerías

  • Bootstrap 5.3.0 para grilla, botones, formularios y modales. Hay un limpiador de fondos de modal huérfanos (js/agenda-cleanup.js) porque abrir un modal desde otro dejaba el backdrop pegado.
  • Tablas oscuras: css/dark-tables.css resetea las tablas de Bootstrap y aplica un tema oscuro basado en variables CSS (--bg-card, --border, --text-primary, etc.). Regla: incluirlo después de Bootstrap, nunca parchear variables de Bootstrap una por una. Preferencia del negocio: tablas compactas, una línea por celda.
  • Mapas: Leaflet 1.9.4 con teselas de OpenStreetMap (y una capa satelital de Esri), polígonos de provincias, cantones y distritos desde un KML y JSON propios, Leaflet.heat para mapas de calor. Google Maps JS se usa en la agenda para cálculo de traslados. Geocodificación inversa con Nominatim y rutas con OSRM (ambos permitidos en la CSP).
  • Gráficos: Chart.js 4.4 con el plugin de etiquetas de datos.
  • PDF e imágenes: jsPDF 2.5.1 (+ autotable), html2canvas 1.4.1 para capturar pantallas y armar imágenes de citas, QRCode para certificados y gafetes, JSZip para descargas múltiples, SheetJS para exportar a Excel.
  • Compresión de imágenes antes de subir (js/agenda-images.js): lado mayor máximo 1.280 px, JPEG calidad 0,72, y si pasa de 350 KB baja la calidad en pasos hasta un mínimo de 0,4. Archivos que no son imagen (video, PDF) se suben sin comprimir con tope de 40 MB. Ruta en Storage por entidad (citas/<id>/...).
  • Dictado: reconocimiento de voz del navegador para comentarios, con corrección posterior por IA (mejorarTexto).
  • Nada de filas nuevas para cosas chicas: un botón, contador o filtro va en el espacio que ya existe (al final del título, dentro del campo) o como ícono con title, para no empujar contenido hacia abajo.
  • XSS: todo texto de usuario en innerHTML pasa por escapeHTML.
  • Colores de rutas: 10 colores fijos (Ruta 1 a 10) que se repiten para 11 a 20, usados igual en agenda, alisto y mapas.

8.2 Zona horaria

  • Costa Rica es UTC-6 fijo, sin horario de verano. El servidor corre en UTC; el navegador del equipo está en hora local.
  • En el navegador se formatea la fecha con los componentes locales (getFullYear, getMonth, getDate), nunca con toISOString() (que da UTC y entre las 18:00 y la medianoche ya es el día siguiente; el lint lo marca).
  • En el servidor: new Date(t - 6h).toISOString().slice(0,10), o toLocaleDateString('en-CA', {timeZone: 'America/Costa_Rica'}) para YYYY-MM-DD, y toLocaleString('es-CR', {timeZone: 'America/Costa_Rica'}) para mostrar.
  • El campo inicio_gmt6 de las citas es un Timestamp con el instante real; el nombre recuerda que se interpreta en GMT-6.

8.3 Día operacional (5:00 a 4:59)

El día de trabajo va de las 5:00 a las 4:59 del día siguiente (hay turnos nocturnos). Una cita a la 1:00 del martes pertenece al lunes. Se indexa con fecha_dia y fecha_mes calculados al guardar:

función camposIndexados(inicio):
    f = inicio - 6 horas                  // llevar el instante a hora de Costa Rica (en UTC)
    si f.horaUTC < 5: f = f - 1 día
    fecha_dia = AAAA-MM-DD de f
    fecha_mes = AAAA-MM de f

Consultar un día: where('fecha_dia', '==', 'AAAA-MM-DD'). El turno nocturno cuenta en el día en que empezó (también en resúmenes de velocidad y presencia). El corte de las 5:00 también decide si una sesión de trabajo abierta es "de hoy".

8.4 Moneda

  • Colones costarricenses, sin decimales: 20000 significa ₡20.000.
  • Formato único (shared/moneda.js): es-CR, punto de miles, sin decimales. numero(n) → "1.234.567", colones(n) → "₡1.234.567", con opción de espacio. parseColones(texto) entiende "₡1.234.567", "1,234,567", "1.234,50" y negativos (si hay coma y punto, el último es el decimal; uno solo seguido de exactamente 3 dígitos es separador de miles). Reemplazó 45 formateadores distintos.
  • En las reglas, los montos son números enteros y se comparan antes y después de la escritura.
  • El presupuesto de anuncios en Meta se expresa en colones enteros.

9. Costos y rendimiento: lo aprendido

Lo que dejó documentado el código y las auditorías de costo:

  1. El costo dominante de Firestore fue transferencia de datos (bytes que salen), no cantidad de lecturas. Una auditoría atribuyó el gasto a las citas, se migró el historial fuera del documento (bajó el peso de cada cita de 11 a 13 KB a unos 2,6 KB y las lecturas diarias a un tercio), pero los GB transferidos no se movieron. La causa real eran los listeners del CRM que traían colecciones enteras (unos 16.000 documentos por pestaña en cada recarga). Lección: mirar la exportación de facturación por SKU y por hora antes de asumir la causa; un piso plano de madrugada es un listener vivo sin nadie mirando.
  2. Listeners con tope medido contra datos reales. Los streams del CRM pasaron a: lista "caliente" de 800, cartera propia de 5.000 (la más grande real era de unos 4.400) y clientes de 3.500 (había unos 3.000). Bajar un tope sin contar puede esconder datos de verdad. Los totales por categoría se obtienen con getCountFromServer (agregación, no descarga documentos) y las categorías se cargan al entrar a ellas, con relectura a los 2 min.
  3. Pausar streams en segundo plano. Un stream grande que quedaba vivo toda la noche con el reparto automático encendido transfería del orden de 1 GB por hora; se reemplazó por uno chico de 200 documentos por recencia.
  4. No guardar historiales dentro del documento principal. El historial completo de cada cita vive en citas/{id}/extras/historial; la cita conserva solo un extracto de los campos que los reportes leen en masa y ultima_edicion.
  5. Caché persistente del SDK: se probó y se revirtió por el fallo del gestor multipestaña (ver 2.3). Queda pendiente con condiciones claras.
  6. Polling y listeners caros identificados: el badge de tickets que leía toda la colección cada 30 s pasó a getCountFromServer; la presencia en vivo con onSnapshot sobre todos los empleados (cada latido re-entregaba todo); consultas sin rango de fecha en edición de horas; páginas que cargaban meses de citas al abrir. Hay unos 78 onSnapshot en 26 archivos: cada uno es candidato a revisar.
  7. Cachés de servidor para cálculos caros: tactico_ventas_cache (minutos, compartida entre todos), plantillas_stats (barrido de unas 65 mil lecturas), cache_lealtad, reporte de escritura con candado de 12 h, resumen mensual de ventas que recalcula solo la cola reciente usando fragmentos congelados de meses anteriores (TTL 5 min), atributos del CRM incrementales (reescanear 82 mil mensajes cada noche serían unos 2,5 millones de lecturas al mes).
  8. Caché del cliente: fichas de empleados en sessionStorage 15 min; verificación de empleado en servidor 5 min por uid.
  9. Firma estable antes de reaccionar a cambios: comparar objetos con JSON.stringify sin ordenar claves provocó relecturas en bucle.
  10. Reintentos que no se cortan: una cuenta desactivada reintentaba la lectura de su ficha durante diez minutos (miles de lecturas). Ahora los fallos administrativos cortan el ciclo.
  11. Registrador de lecturas (FirestoreReadTracker → diagnostico_lecturas_firestore) y página reporte_diagnostico_lecturas.html para ver qué página y función leen más.
  12. No escribir en exceso: track-location ignora replaceState (una página lo llamaba en cada clic) y descarta lecturas de más de 500 m de error; presencia escribe solo desde la pestaña líder.
  13. Reglas abiertas cuestan dinero: mientras las reglas estuvieron laxas, bots podían leer y escribir gratis a cuenta de la empresa. Las rutas públicas se acotan en forma y tamaño.
  14. Directions de Google: el modo con tráfico en vivo cuesta casi el doble por llamada; se recomendó usarlo solo para el día en curso, no recalcular fechas pasadas, recalcular solo rutas desactualizadas y usar una matriz de distancias en lugar de una llamada por resultado. La llave de mapas está restringida a las APIs que se usan.
  15. Documentos con tope de tamaño: los recorridos de GPS se parten en bloques de 6 horas para no acercarse a 1 MB por documento.
  16. Respaldos de Firestore: la retención semanal se bajó de 84 a 28 días.
  17. Índices y TTL: 54 índices compuestos declarados; políticas TTL en presence_live, presence_days y _rate_limits (campos expireAt/expira, configuradas en consola).

10. Inconsistencias conocidas que conviene no copiar

  • Tres copias de la tabla de herencia de permisos (navegador, pantalla de Equipo, servidor) con diferencias, más la herencia repetida a mano en las reglas.
  • Una página sin entrada en PAGE_PERMISSIONS queda abierta a cualquier empleado: el valor por defecto debería ser cerrado.
  • esGerencia en las reglas mira solo departamento y cargo; en el servidor también acepta permisos.gerencia.
  • Las reglas nombran usuarios concretos en dos lugares (un destino especial de leads y una persona autorizada a editar la calidad de venta); conviene modelarlo como permiso.
  • index.js de funciones concentra 169 exports y más de 9.000 líneas; los módulos con fábrica (construir({...})) son el patrón a seguir.
  • El registro de cuentas de Firebase Auth quedó abierto por una página pública con sesión anónima; toda la seguridad descansa en el lookup. Un sistema nuevo debería cerrar el registro y usar otra vía para lo anónimo.
02

Operación de campo: agenda, rutas y servicio

Este capítulo describe todo lo que va desde que una cita se agenda hasta que el técnico la ejecuta, la reporta, la cobra y el cliente recibe su reporte, certificado o encuesta. Incluye la planificación de rutas (ruteador), la salida de bodega (despacho y alisto), la vista del técnico (ruta.html), los procesos de servidor que vigilan la operación y las pantallas de logística y geografía.

Convenciones del capítulo:

  • "Cita" es un documento de la colección citas. Casi todo su contenido vive en el mapa metadata.
  • "Ruta" es un texto fijo "Ruta 1" a "Ruta 20" o "Sin asignar".
  • Los montos son colones enteros, sin céntimos.
  • La zona horaria es Costa Rica, UTC-6 fijo todo el año (sin horario de verano). Todo el sistema calcula la hora local restando 6 horas a UTC y leyendo los getters UTC, sin depender de la zona del navegador o del servidor.
  • "La central" es la sede de la empresa, de donde salen y a donde vuelven todas las rutas. Sus coordenadas están fijas en el código de varias pantallas y funciones.
  • "Gerencia" es un rol: empleado con departamento gerencia (en las reglas de Firestore también cuenta un cargo que contenga "gerenc" o "gerent").

1. Conceptos transversales

1.1 Día operacional

La jornada va de las 5:00 a. m. a las 4:59 a. m. del día siguiente. Una cita a las 2:00 a. m. pertenece al día anterior. Esto permite que las rutas nocturnas (que trabajan hasta la madrugada) queden en un solo día.

Cada cita guarda dos índices derivados que se recalculan en cada guardado que toque inicio_gmt6:

buildCitaIndexedFields(cita):
  g6 = inicio_gmt6 - 6 horas            // hora local de Costa Rica, leída con getters UTC
  si g6.horaUTC < 5: g6 = g6 - 1 día     // la madrugada es de la jornada anterior
  fecha_dia = g6 como 'AAAA-MM-DD'
  fecha_mes = g6 como 'AAAA-MM'
  telefono_normalizado = normalizarTelefono(telefono)   // '506' + 8 dígitos, o '+' + dígitos si es extranjero
  • Consulta canónica de un día: citas where fecha_dia == 'AAAA-MM-DD'.
  • Consulta de un mes: citas where fecha_mes == 'AAAA-MM'.
  • En el eje del cronograma las horas se pasan a minutos desde las 5 a. m.: minOp = h >= 5 ? (h-5)*60 + m : (h+19)*60 + m (las 2 a. m. son 1260).
  • inicio_gmt6 es un Timestamp UTC real, a pesar del nombre.

Lecciones del código:

  • Con el día calendario las citas de madrugada quedaban fuera de su ruta y las que cruzan medianoche nunca se comparaban para detectar choques.
  • Revertir una hora desde el historial sin recalcular fecha_dia dejaba la cita archivada en el día equivocado.
  • Hay un segundo concepto, el día contable de inventario (calendario de 00:00 a 23:59 en GMT-6), que no se debe confundir con el operacional.

1.2 Rutas y turnos

Rutas Turno Notas
1 a 10 Diurnas Ventana típica de 7:00 a 17:00
11 a 17 Nocturnas o "especiales" Ventana de 15:00 a 2:00 del día siguiente; desde sep-2026 exigen pantallazo de confirmación
18 y 19 Rurales Por defecto se rotulan "Rural 1" y "Rural 2"; las atiende un socio externo con su propio vehículo y bodega; no se alistan desde la central
20 Canceladas Cancelar una cita es moverla aquí. No se convoca ni se despacha. Excluida de casi todos los reportes
Sin asignar Bolsa Citas aún sin ruta; columna plegable en el cronograma

Colores: diez colores fijos para las rutas 1 a 10; las rutas 11 a 20 repiten los mismos colores en el mismo orden. "Sin asignar" es gris pizarra. Las citas ficticias se pintan rojo oscuro.

Normalización de ruta (usada en todas partes): "9", 9, "ruta 9" y "Ruta 9" se convierten en "Ruta 9" si el número está entre 1 y 20; cualquier otra cosa vacía es "Sin asignar". Sin normalizar, la misma ruta se partía en dos claves y una de ellas quedaba sin ruteador.

1.3 Nadie borra citas

  • Las reglas de Firestore tienen allow delete: if false sobre citas, también para gerencia.
  • "Cancelar" es mover a Ruta 20 con motivo y justificación.
  • El importador CSV, desde sep-2026, cancela en vez de borrar.
  • Además existe (fuera de este capítulo) una foto diaria de ventas a las 11:45 p. m. que avisa si una cita prestada se canceló, se puso en cero o desapareció, más respaldos y recuperación a un punto en el tiempo.

1.4 Escritura por campo, nunca el mapa entero

Regla de oro de todo el sistema de campo: cada pantalla escribe con rutas de punto ('metadata.hora_ingreso', 'metadata.imagenes') y relee la cita fresca del servidor justo antes de escribir. Reemplazar metadata completo desde la copia en memoria borró varias veces lo que otro módulo había escrito después (ingreso y GPS del técnico, fotos del reporte, encuestas, cancelaciones). Excepciones conocidas y mitigadas con relectura: la edición del reporte desde oficina.

Corolarios:

  • Las listas se modifican con arrayUnion / arrayRemove con el valor exacto, nunca con una ruta de índice (lista.45.campo): Firestore convierte el arreglo en mapa.
  • serverTimestamp() no se puede meter dentro de un arreglo; el historial usa Timestamp.now() o texto.

1.5 Fuente de identidad del cliente

  • El cliente se identifica por teléfono. clientes/{telefono} guarda nombre, telefonoFormateado, sucursales[] (índices), totalCitas, fechaCreacion, fechaUltimaActualizacion.
  • Sucursales: sucursales/{telefono}_{indice con 3 dígitos} con indice y datos de la sede.
  • La cita guarda metadata.cliente_nombre como copia para pintar rápido; el nombre oficial vive en clientes.
  • Teléfono nacional: 8 dígitos, se guarda como 506 + 8. Extranjero: empieza con +, de 9 a 15 dígitos, y la agenda pide una verificación humana (una suma) más una confirmación.

2. Ciclo de vida de una cita

El ciclo de vida no es una máquina de estados. metadata.estado vale agendada en casi todas las citas (agenda, clonar, recurrentes, ficticias, bot); los procesos tratan como cancelada cualquier estado que contenga "cancel" para tolerar datos viejos. Cada etapa se define por la presencia de ciertos campos.

                    +-------------------------------------------+
                    |  CREADA                                    |
                    |  doc citas/{id} existe                     |
                    |  metadata.estado='agendada'                |
                    |  confirmada24_48={confirmada:false}         |
                    |  monto, vendedores, canal, creado_por,      |
                    |  fecha_creacion, fecha_dia, fecha_mes       |
                    |  historial: entrada metadata.monto          |
                    |  (origen 'creacion_cita')                   |
                    +---------------------+-----------------------+
                                          |
          a los 3 min: envío automático   |   triggers: citaOverlapCheck,
          al grupo Ventas (si califica)   |   stampClienteOnCita, metaCapi (Lead),
                                          |   recuperador de cartera, garantías
                                          v
                    +-------------------------------------------+
                    |  CONFIRMADA                                |
                    |  confirmada24_48={confirmada:true, ts ISO} |
                    |  + imagen_confirmacion (rutas 11 a 17)     |
                    |  ventana: 3 días operacionales antes       |
                    |  plazo: 3:30 p. m. del día anterior         |
                    +---------------------+-----------------------+
                                          |
            si a las 3:30 p. m. no está   |  (metaCapi: Schedule)
            confirmada: venta_no_calidad  |
            = true, confirmacion_vencida  |
                                          v
                    +-------------------------------------------+
                    |  EN RUTA                                   |
                    |  metadata.ruta = Ruta 1..19                |
                    |  ruteador/{fecha}_{Ruta-N}.tecnicos[]      |
                    |  horaConvocatoria, vehiculos[]             |
                    |  despacho marcado en el ruteador            |
                    +---------------------+-----------------------+
                                          |
                                          v
                    +-------------------------------------------+
                    |  EJECUTADA                                 |
                    |  metadata.hora_ingreso (texto) + gps_ingreso|
                    |  metadata.hora_salida  (texto) + gps_salida |
                    |  gps_dudoso / alerta_lejos / culpa_tecnico |
                    +---------------------+-----------------------+
                                          |  (la salida exige reporte completo
                                          |   y método de pago con respaldo)
                                          v
                    +-------------------------------------------+
                    |  REPORTADA                                 |
                    |  metadata.reporte.condicion verde|amarillo|rojo|
                    |  reporte.grupos, plagas[], secciones.*.fotos|
                    |  reportado_por, reportado_en               |
                    +---------------------+-----------------------+
                                          |  rojo cerrado: ticket al vendedor
                                          |  + reportes_publicos/{token} + revisión IA
                                          |  portal: portalPushAlCerrarReporte
                                          v
                    +-------------------------------------------+
                    |  COBRADA                                   |
                    |  metadata.pagos[] {metodo, monto, linea?}   |
                    |  metadata.metodoPago (el de la parte mayor) |
                    |  metadata.precio_final_rutero (total)      |
                    |  voucherPago / reciboEfectivo              |
                    +---------------------+-----------------------+
                                          |  (metaCapi: Purchase cuando monto > 0)
                                          v
                    +-------------------------------------------+
                    |  POST SERVICIO                             |
                    |  encuestas_citas/{citaId} pendiente→respondida|
                    |  certificados_emitidos/{serial}            |
                    |  satisfaccion_citas/{citaId} (madrugada)   |
                    |  garantias/{token} si el cliente reclama   |
                    +-------------------------------------------+

    Desde cualquier etapa:
                    +-------------------------------------------+
                    |  CANCELADA                                 |
                    |  metadata.ruta = 'Ruta 20'                 |
                    |  motivo_cancelacion (lista cerrada)        |
                    |  justificacion_cancelacion (>= 50 chars)   |
                    |  fecha_cancelacion (solo por arrastre)      |
                    |  luego revision_cancelacion (gerencia)     |
                    |  salir de Ruta 20 borra los 4 campos        |
                    +-------------------------------------------+

2.1 Tabla de etapas

Etapa Qué la define Campos que cambian Quién escribe
Creada Existe el documento Raíz completa, metadata.estado='agendada', confirmada24_48, monto, vendedores, canal, creado_por, fecha_creacion, índices; primera entrada de historial del monto Agenda (formulario, recurrentes, clonar, Compás), ruta.html (ficticia), bot de ventas vía Optimus (servidor), garantías
Confirmada confirmada24_48.confirmada === true (más imagen_confirmacion en rutas 11 a 17 desde el 10-sep-2026) confirmada24_48 {confirmada, timestamp}, imagen_confirmacion, cnf_tipo, nombre_saludo Vendedor (botón CNF y Guardar), bot de ventas
Confirmación vencida No confirmada a las 3:30 p. m. del día anterior venta_no_calidad=true, venta_no_calidad_justificacion, venta_no_calidad_evidencias[], confirmacion_vencida{} Servidor (schedule 15:30)
En ruta Ruta 1 a 19 y técnicos en el ruteador metadata.ruta; técnicos y vehículos viven en ruteador, no en la cita Agenda, migración, arrastre
Ejecutada Hay ingreso y salida hora_ingreso, gps_ingreso, hora_salida, gps_salida ruta.html
Reportada Existe metadata.reporte.condicion reporte.*, fotos por sección ruta.html, oficina
Cobrada Hay pagos[] o metodoPago pagos[], metodoPago, precio_final_rutero, respaldos ruta.html, agenda (hasta el plazo de arqueo)
Cancelada metadata.ruta === 'Ruta 20' ruta, motivo_cancelacion, justificacion_cancelacion, fecha_cancelacion, luego revision_cancelacion Agenda (modal, arrastre, migración), importador

guardarCita conserva el estado guardado (estado: base.estado || 'agendada') para que editar no vuelva a "agendada" lo que otro proceso hubiera marcado.

2.2 Estado visible en el cronograma

estadoDisplayCita: Ruta 20 se muestra "Cancelada"; rutas 1 a 19 se muestran "Realizada" si la hora de inicio ya pasó, si no "Agendada". Es un cálculo de pantalla, no un campo.

Semáforo de cada cita en la vista del técnico:

arqueado = ficticia || monto <= 0 || pagos tiene algún método || metodoPago
hechos   = contar(true en [ingreso, salida, arqueado])   // la ficticia cuenta ingreso y salida como hechos
3 → verde ; 0 → rojo ; 1 o 2 → amarillo ("Falta ingreso, salida, arqueo de pago")

3. Modelo de datos de la cita

3.1 Campos de primer nivel

Campo Tipo Significado
telefono texto Teléfono del cliente, fuente de verdad
telefono_normalizado texto 506XXXXXXXX o +dígitos; índice
inicio_gmt6 Timestamp Hora de inicio (UTC real)
duracion_minutos número Opciones: 15, 30, 45, 60, 75, 90, 105, 120, 180, 240, 300, 360, 420, 480 (tope 480). Por defecto 60 en todo cálculo
fecha_dia, fecha_mes texto Índices del día y mes operacional
fecha_creacion, fecha_actualizacion Timestamp serverTimestamp()
creado_por texto Username de quien creó; las reglas exigen que sea quien escribe
version_esquema número 1
_overlap_detected, _overlap_with[], _overlap_checked_at varios Solo los escribe el servidor al detectar choque
whatsapp_coordinacion mapa Por qué línea de WhatsApp de la empresa se coordina con el cliente (servidor, cada hora)
historial_ediciones arreglo Historial viejo de pagos que algunas escrituras aún agregan

3.2 Campos de metadata

Grupo Campos
Cliente y lugar cliente_nombre, sucursalIndice, direccion (texto con enlaces de navegación), whatsapp (enlace wa.me), coordenadas {lat, lng, texto}, otras_senas (alias viejo otras_señas), provinciaId, cantonId, distritoId, ubicacion_calculada_en, ubicacion_precision (pin, link, geocode, referencia, distrito), coordenadas_corregidas {...}
Ruta y tiempo ruta, turno, solo_esta_hora, solo_esta_hora_foto
Servicio servicios[] {id, cantidad, precio, comentarios, coordenadas?}, productos[] {id, cantidad, precio} (legado), equipo[] (nombres de herramientas), excluidos {productos[], herramientas[]}, enfoquePlaga (texto libre), plagas[], certificados (un establecimiento por renglón), validez_dias (30 por defecto), observaciones (obligatorio)
Venta monto, monto_es_manual, total_servicios, total_productos, costo_servicio (0), detalle_cotizacion {servicios[], productos[]}, vendedores[] (usernames), excepcion_pesos_vendedores (pesos enteros que suman 100, o null), canal (id de canales, obligatorio), topo, es_cotizacion
Origen modalidad_cita_creada (drag_and_drop, optimizador_algoritmo, mapa_logistica, desde_cotizacion, excepcion_ruta_hora, bot_optimus), origen_cita (p. ej. compas), justificacion_sin_optimus, desde_cotizacion_id, desde_cotizacion_numero
Confirmación confirmada24_48 {confirmada, timestamp, por?}, imagen_confirmacion {url, path, name, sizeKB, uploadedAt, uploadedBy}, cnf_tipo (casas o locales), nombre_saludo
Calidad de venta venta_no_calidad, venta_no_calidad_justificacion, venta_no_calidad_evidencias[], confirmacion_vencida {plazo, limiteMs, revisadoMs, motivo, avisada, constancia, revertida?}
Cancelación motivo_cancelacion, justificacion_cancelacion, fecha_cancelacion, revision_cancelacion {texto, usuario, fecha}
Ejecución (técnico) hora_ingreso, hora_salida (texto es-CR), gps_ingreso, gps_salida {lat, lng, accuracy, timestamp, gps_dudoso?, alerta_lejos?, culpa_tecnico?, distancia_cita_metros?, manual_fix?}, apoyos.<Ruta_N>.* (ingreso y salida de la ruta que apoya)
Reporte reporte {...} (ver capítulo de ruta.html), novedad_tecnico
Cobro pagos[] {metodo, monto, linea?, motivo?}, metodoPago, motivoPago, precio_final_rutero, voucherPago {url, por, en}, reciboEfectivo {url, por, en}, numeroFactura
Adjuntos imagenes[] {path, url, name, tipo, sizeKB, uploadedAt, uploadedBy}
Historial historial[] (extracto), ultima_edicion {usuario, timestamp, campo}
Varios reacomodo_libre, es_ficticio, cita_origen_id, es_garantia, garantia_de, garantiaId, portal_sedeId

3.3 Subcolecciones y colecciones relacionadas

Colección Contenido
citas/{id}/extras/historial { historial: [...] } completo; solo se puede agregar
clientes/{tel}, sucursales/{tel}_{nnn} Identidad del cliente y sedes
analitica_clientes/{tel} Totales por teléfono mantenidos por trigger
citas_monto_log Bitácora de cambios de monto (solo servidor)
alertas_confirmacion/{dia}_1530 Pendientes de confirmar y resultado del plazo
confirmacion_no_necesaria/{tel8} Teléfonos exentos de confirmar
movimientos_ajenos Movimientos de citas de otro vendedor
notas_mentales Tareas personales
ruteador/{fecha}_{Ruta-N} Técnicos, vehículos, convocatoria, bloqueos, traslados, checklist
encuestas_citas/{citaId} Encuesta por cita
tickets_rojo/{citaId}, reportes_publicos/{token}, config_tickets_rojo/general Citas en rojo
certificados_emitidos/{serial} Certificados verificables
garantias/{token} Reclamos de garantía
ventas_envios/{citaId}, ventas_movidas Rastro de envíos al grupo Ventas
wa_equipo_salida Cola de mensajes que despacha el agente de WhatsApp de empresa

4. Agenda (agenda.html): carga, cronograma y formulario de cita

4.1 Propósito y usuarios

agenda.html es la pantalla central del sistema. La usan ventas (crear y editar citas), coordinación y gerencia (mover citas, asignar técnicos y vehículos, revisar cancelaciones) y bodega (ver qué lleva cada ruta). Permiso de página: calendario.

Es un solo HTML grande (decenas de miles de líneas) con un <script type="module"> dividido por secciones (// SECCIÓN: ...) y módulos auxiliares en js/agenda-*.js. Para reconstruirlo no hace falta replicar esa forma: conviene separar en módulos de carga, cronograma, formulario, guardado, historial, ruteador y vehículos.

Archivo Responsabilidad
agenda.html Orquestación, cronograma, formulario, guardado, historial, migración, ruteador
js/agenda-globals.js Estado global y apertura del modal de edición (abrirModalEdicion)
js/agenda-utils.js Helpers de campo (obtenerCampoCita), día operacional, teléfono, moneda
js/cronograma-ruta.js Dibujo de la columna de cada ruta y de los bloques de cita
js/agenda-tabla-citas.js Tabla analítica de solo lectura por rango
js/agenda-search.js Buscador de citas por texto y teléfono, acciones rápidas
js/agenda-images.js Adjuntos con compresión
js/agenda-foto-cita.js Pantallazo de la Vista Ventas y botón "Enviar a Ventas"
js/agenda-cita-extras.js Mueve los recuadros de certificados, especiales y herramientas a la Vista Ventas
js/agenda-cotizaciones.js Puente con el módulo de cotizaciones
js/confirmacion-plazos.js, js/agenda-aviso-confirmaciones.js, js/agenda-imagen-confirmacion.js, js/plantilla-confirmacion.js Confirmación de citas
js/cita-historial.js Historial completo en subcolección
js/agenda-movimientos-ajenos.js, js/agenda-aviso-especiales-cita.js, js/agenda-reasignar-especiales.js, js/agenda-ruteador-guard.js Porteros al mover citas o técnicos
js/agenda-fabs.js, js/agenda-fab-rojo.js Botones flotantes: sin ruta, cancelaciones, citas en rojo

4.2 Carga de datos

No hay listeners en tiempo real para las citas: todo se lee con getDocs puntual (el único onSnapshot de la página es el de garantías; los avisos de confirmación y la burbuja roja usan sus propios listeners).

Función Consulta Global que llena
cargarCitasDiaCalendario citas where fecha_dia == dia todasLasCitas, ordenadas por inicio
cargarCitasMesCalendario citas where fecha_mes == mes citasMesCalendarioCache (podada)
cargarCitasCreadasHoyCalendario citas where fecha_creacion entre 00:00 y 23:59:59 del día calendario, orden descendente citasCreadasHoyCache
cargarMapaEncuestas encuestas_citas y justificaciones_sin_encuesta por documentId() in en tandas de 30, más colecciones viejas mapas de encuestas
Catálogos servicios (orden por nombre), productos where activo == true (con plan B sin índice), herramientas_alisto, canales, herramientas (legado) todosLosServicios, todosLosProductos, etc.

Orden de carga en cargarDatos (con candado para no correr dos veces):

  1. En paralelo: catálogos (solo si están vacíos) y las citas del día.
  2. Se dibuja el cronograma.
  3. Cuando el navegador queda libre (doble requestAnimationFrame, luego requestIdleCallback con tope de 1 s y piso de 1,5 s): encuestas y después el mes entero.
  4. Se procesan los parámetros de URL: editarCita, rutero, desdeCotizacion, clonar+hora+ruta, nuevaCita=1 (con hora, ruta, duracion, modalidad, origen, lat, lng), agendarGarantia.

Poda del caché mensual: se quitan historial, reporte, detalle_cotizacion e historial_ediciones, porque el mes completo pesaba unas 860 citas y 3,4 MB y el calendario solo mira monto, ruta, equipo y fechas.

Lecciones:

  • Cargar encuestas y el mes antes del cronograma hacía que la primera cita tardara 11 s en verse.
  • En una pestaña en segundo plano el navegador no dispara requestAnimationFrame: esperar el pintado ahí significaba no cargar nunca.
  • Encadenar catálogos y citas costaba unos 3 s antes de que arrancara la consulta de citas.

4.3 Cronograma

  • Escala: 48 px por hora; el día operacional completo mide 24 horas (de 5 a. m. a 5 a. m.).
  • Dos secciones: rutas diurnas (1 a 10) y nocturnas o especiales (11 a 20). Las rutas 9, 10, 16 y 17 se ocultan si están vacías (botón "+ Rutas"). Las 11 a 19 llevan fondo rayado; la 20 un ícono de prohibido.
  • Vista combinada opcional (preferencia local): junta todas las rutas con citas en una sola sección.
  • Recorte de la sección diurna: se corta a las 17:00 salvo que alguna cita diurna termine después.

Bloque de cita:

min5   = minutos desde las 5 a. m.
top    = min5 / 60 * PX_H
height = max(duracion / 60 * PX_H, 20)
si es cita de apoyo (copiada de otra ruta): se recorta a la franja de apoyo, z-index menor, no arrastrable
  • No hay algoritmo de carriles en el cronograma de la agenda: dos citas que chocan se dibujan una encima de otra. El choque se impide al guardar o al soltar.
  • Contenido: cinta de provincia, cantón y distrito (o "Dirección pendiente"), tres puntos de estado (confirmada, encuesta, pago), insignia del primer vendedor, hora, precio (o "Precio 0" en rojo), nombre con distintivo de cliente de cartera, check si tiene ingreso y salida, alerta si no tiene GPS, indicador de técnicos en sitio (última cita con ingreso sin salida) o "en tránsito", borde de comisión en riesgo (falta confirmación, encuesta, pago o GPS).
  • Columna: capa gris del tiempo transcurrido, línea de hora de convocatoria (gris punteada a las 5:30 si no hay hora), franjas bloqueadas del ruteador.
  • Cabecera de cada ruta: monto en miles con ícono verde si supera ₡200.000, alerta entre ₡100.000 y ₡200.000, cruz roja por debajo.
  • Al hacer clic en una cita se abre la Vista Ventas (detalle de solo lectura con acciones); la edición completa es otro modal.

4.4 Arrastrar y soltar citas

Mover una cita existente (aplicarDropCitaEnTimeline):

y     = posición del puntero - tope del timeline - offset con que se agarró
min5  = clamp(0..1439, y / PX_H * 60) redondeado a 15
si min5 < 1140: hora = min5 + 300 (mismo día) ; si no: hora = min5 - 1140 (día siguiente)
duracion = max(15, redondeada a 15)
validaciones en orden:
  1. bloqueo por antigüedad (más de 7 días) o día pasado sin ser gerencia
  2. franjas bloqueadas del ruteador (contadas en día operacional)
  3. si destino es Ruta 20: buscar hueco libre del día; si no, chequear solape en la ruta destino
  4. solo gerencia puede dejarla antes del inicio del día operacional actual
  5. aviso de productos especiales ya alistados (ver 5.11)
  6. Ruta 20 pide justificación
  7. aviso si la cita es de otro vendedor (movimientos ajenos)
relee la cita del servidor; writeBatch con solo inicio_gmt6, duracion_minutos, metadata.ruta,
fecha_dia, fecha_mes y el historial; al salir de Ruta 20 borra los campos de cancelación
muestra vista previa "CITA MOVIDA" y, al confirmar, la manda al grupo Ventas (sección 11.3)

Crear arrastrando en zona vacía: desde el 29-oct-2026 no agenda, lleva a Compás (sección 12.1) con la duración y el turno. Excepción temporal hasta el 30-nov-2026: se puede agendar ahí mismo como excepcion_ruta_hora escribiendo un motivo de al menos 20 caracteres, que queda en metadata.justificacion_sin_optimus. En táctil exige mantener presionado 220 ms y se cancela si el dedo se mueve más de 12 px.

Lecciones:

  • Escribir la metadata completa al mover borraba hora de ingreso, GPS, reporte y fotos del técnico.
  • Con una búsqueda activa las citas ocultas no se comparaban y se agendaban dos en la misma franja: el solape se valida contra todas las citas en memoria, no contra las filtradas.
  • Con draggable=false en citas protegidas, quien no es gerencia arrastraba y no pasaba nada; ahora se deja agarrar y al soltar se explica el motivo.

4.5 Formulario de cita: campos y reglas

Campo Regla
Teléfono Obligatorio. 8 dígitos nacionales o extranjero con + (9 a 15 dígitos, suma de verificación y confirmación)
Inicio y duración Obligatorios. La hora de fin se muestra calculada
Ruta Obligatoria. Selector propio con colores
Canal Obligatorio. Id de canales. Las líneas de WhatsApp de la empresa ("Tel. NNNN-NNNN - Persona") van en un grupo aparte de "Otros canales" para no elegir una por otra (error de vecindad en el desplegable)
Vendedores Al menos uno. Búsqueda por nombre legal o preferido; se guarda el username
Pesos de vendedores Solo con 2 o más vendedores y solo gerencia. Por defecto reparto entero que suma 100 (3 vendedores: 34, 33, 33). Si cambia la lista de vendedores y el número de pesos no coincide, se anulan
Observaciones Obligatorio ("Comentarios muy importantes")
Enfoque de plaga Texto libre de una línea, hasta 200 caracteres. El selector viejo de 18 plagas quedó fuera de uso; se sigue guardando plagas: [{nombre: texto, comentarios: ''}] por compatibilidad
Servicios Búsqueda en catálogo. El mismo servicio puede ir varias veces con distinto precio o comentario. Cantidad y precio unitario editables por fila. GPS opcional por servicio
Productos Solo registro de costo interno (ver 4.7)
Herramientas Búsqueda en herramientas_alisto con saldo (ver 4.8)
Certificados Un establecimiento por renglón; validez en días (30 por defecto, editable con permiso)
GPS Pegado como coordenadas o enlace. "El GPS manda": provincia, cantón y distrito se deducen del punto y sus selectores quedan deshabilitados. Fuera del rectángulo de Costa Rica se rechaza o pide confirmación (protección contra coordenadas invertidas)
Solo esta hora En rutas 1 a 10 exige foto del chat donde el cliente pide esa hora exacta
Confirmada 24/48 h Habilitado solo dentro de la ventana (3 días operacionales antes) y con GPS
Venta no comisionable Solo gerencia o una usuaria de asistencia de gerencia autorizada; exige justificación y al menos un pantallazo
Método de pago, factura Editables hasta el plazo de arqueo
Es cotización, topo Banderas

Al abrir una cita para editar:

  • Cita protegida: si no es gerencia, no es Ruta 20 y la cita tiene más de 7 días, se oculta Guardar y aparece "Las citas con más de 7 días no se pueden modificar".
  • Banderas reiniciadas: serviciosProductosModificadosPorUsuario.edit = false y editandoMontoVentaManual.edit = false, para no recalcular el monto de citas viejas o importadas mientras el usuario no toque servicios.
  • Si metadata.servicios está vacío, se reconstruye desde detalle_cotizacion.servicios emparejando por nombre.
  • Comparación real contra lo previsto: desviaciones de ingreso o salida desde 2 minutos; duración real en rojo si queda 10 minutos o más por debajo o un 30 % menos, en ámbar si se pasa más de 15 minutos o 40 %; distancia del GPS del técnico al punto de la cita: verde hasta 100 m, ámbar hasta 300 m, rojo más.

4.6 Precio y monto

calcularMontoVenta(prefijo):
  si el usuario fijó el monto a mano (Enter en el campo): no tocar
  si es edición y no se tocaron servicios ni productos: no tocar      // protege citas viejas o importadas
  totalServicios = suma(cantidad * precio) de los servicios
  si no hay ítems y el monto actual > 0: no pisar
  monto = totalServicios                                                // los productos NO suman
  • Los productos se registran con su costo de inventario, información interna que no se cobra. Antes se sumaban y registrar químicos inflaba la cuenta del cliente.
  • Resumen financiero en pantalla: margen = precioCliente - costoProductos (rojo si es negativo). Se guarda costo_servicio: 0.
  • detalle_cotizacion: servicios[{id, nombre, cantidad, precio_unitario, subtotal}], productos[{id, nombre, cantidad, costo_unitario, subtotal}], todo redondeado a colones enteros.
  • Lectura de montos escritos (extraerValorNumerico): el separador decimal es el último . o , seguido de 1 o 2 dígitos al final; todo lo demás son miles; se redondea a entero. Lección del 27-ago-2026: un total con céntimos formateado como "554 047,08" se leyó como 55.404.708.
  • formatearMontoVenta(0) devuelve vacío a propósito (no mostrar ₡0).
  • Proforma: imagen PNG (no PDF) de 560 px generada con html2canvas a escala 3; se copia al portapapeles y se descarga. descuento = totalServicios > monto ? totalServicios - monto : 0. Los productos se listan sin precio.

4.7 Atribución de la venta a vendedores

atribucion(cita):
  vs = metadata.vendedores
  si excepcion_pesos_vendedores tiene el mismo largo que vs y suma exactamente 100:
      cada vendedor recibe monto * peso / 100
  si no: cada vendedor recibe monto / len(vs)
  sin vendedor: "Sin vendedor"
  se excluye Ruta 20

La tira "Ventas hoy" usa las citas creadas hoy con monto mayor a 0, sin Ruta 20, con esta misma atribución; el primero lleva corona. Las metas mensuales y las barras por día se movieron a otra página (táctico); en la agenda quedó la grilla del mes.

4.8 Herramientas de alisto con saldo

herramientas_alisto {nombre, saldoTotal}: cantidad fija de cada herramienta en toda la empresa (por ejemplo, termonebulizadoras). En la cita se guardan por nombre en metadata.equipo[].

grupo(ruta) = 1..10 → 'diurna' ; 11..20 → 'nocturna' ; otro → sin validación
enUso(nombre, grupo) = cantidad de citas cargadas del grupo (salvo la que se edita)
                       cuyo metadata.equipo contiene ese nombre (sin mayúsculas ni espacios)
disponible = max(0, saldoTotal - enUso - vecesYaPuestaEnEsteFormulario)
disponible <= 0 → opción "agotada", no seleccionable
  • Solo se valida en el buscador, no al guardar. Una herramienta escrita a mano (fuera del catálogo) se marca en naranja "Escrita a mano".
  • metadata.excluidos.herramientas y .productos registran lo que el servicio trae pero esta cita no lleva; se respeta en despacho, alisto y ruta. Hay dos copias de trabajo separadas (editar y nueva) porque con una sola lo quitado al agendar se pegaba a la cita abierta.
  • Panel "lo que la cita incluye": certificados, especiales (productos del servicio con cantidad = servicio.cantidad * pa.cantidadPorUnidad más los sueltos) y herramientas (de la cita más las del servicio, sin repetir).

Huecos conocidos: el conteo puede abarcar el mes si la vista es mensual, las citas de Ruta 20 cuentan en el grupo nocturno y no suma las herramientas asociadas a servicios.

4.9 Clientes, sucursales y adjuntos

  • buscarCliente(tel) → clientes/{tel}. Si no existe se crea al guardar; si existe se actualiza el nombre.
  • Sucursales: cliente.sucursales[] guarda índices; cada sede es sucursales/{tel}_{nnn}. Las coordenadas de la sucursal son la fuente de verdad del punto cuando la cita tiene sucursal.
  • Adjuntos (agenda-images.js): imágenes comprimidas a 1280 px de lado mayor, JPEG calidad 0,72, bajando de a 0,08 hasta 350 KB o calidad 0,4; tope de 40 MB por archivo; HEIC se sube sin comprimir. Storage citas/{citaId}/imagenes/{ts}_{nombre}.{ext}. Se escribe solo metadata.imagenes por ruta de campo. En cita nueva quedan en cola hasta tener id.

4.10 Buscador de citas

  • Texto libre: citas where inicio_gmt6 >= desde orderBy inicio_gmt6 desc limit tope. Por defecto 90 días hacia atrás; opciones de 1 año, 3 años o todo. Topes: 3.000 documentos hasta 1 año, 6.000 más de un año, 10.000 sin límite. Todas las palabras (sin tildes) deben coincidir en teléfono, nombre, dirección, descripción, observaciones, ruta, vendedores o fecha. Máximo 20 resultados.
  • Si el texto es un teléfono, se desvía a búsqueda por teléfono: variantes d, +d, 8 dígitos, 506+8 y +506+8 en telefono_normalizado y telefono, hasta 300 por consulta, máximo 100 resultados.
  • Acciones rápidas desde la tarjeta: marcar CNF, subir pantallazo de confirmación (1600 px, calidad 0,72), corregir GPS.
  • Resumen con IA de comentarios largos (más de 130 caracteres), 25 por llamada; si la función falla se apaga por la sesión.

4.11 Tabla de citas (solo lectura)

agenda-tabla-citas.js: rango libre (aviso si pasa de 62 días), unas 45 columnas agrupadas (cita, ejecución, reporte, encuesta, venta, servicio, cliente, gestión), puntualidad en rojo si el atraso pasa de 15 minutos, "Precio final (ruta)" resaltado si difiere del monto, exportación TSV o CSV. Las ficticias suman monto pero no cuentan como visita. Lección: las horas del técnico en texto "5/9/2026, 10:23" se leían como 9 de mayo; solo se parsea directo el ISO.

4.12 Cotizaciones

  • agenda.html?desdeCotizacion=ID abre una cita nueva prellenada desde cotizaciones/{id}: teléfono, nombre, señas, coordenadas, duración (días × 480 min, horas × 60, tope 720), servicios, precio aprobado (si difiere de la suma, queda como monto manual), vendedor y canal de la cita de visita.
  • Al guardar, la cotización pasa a ganada con citaTrabajoId y una entrada en su bitácora.
  • Estos campos no se copian al clonar.

5. Guardado y control de la cita

5.1 Guardar una cita existente (guardarCita)

Barreras en orden exacto:

  1. Cliente bloqueado (clientes_bloqueo): no se guarda.
  2. Cita protegida (más de 7 días, salvo gerencia o Ruta 20).
  3. Candado contra doble clic (_guardandoCita, se libera en finally). Un segundo Guardar duplicaba el historial.
  4. Obligatorios: teléfono, inicio, duración, ruta, observaciones, canal, al menos un vendedor.
  5. Venta no comisionable marcada: justificación y al menos un pantallazo.
  6. Fecha anterior al inicio del día operacional actual: solo gerencia, salvo que la fecha no haya cambiado (tolerancia 60 s).
  7. Pesos de vendedores: solo gerencia, enteros de 0 a 100 que suman 100.
  8. "Solo esta hora" en rutas 1 a 10: foto obligatoria (Storage solo_esta_hora/{citaId}_{ts}).
  9. Ruta 20: motivo de la lista y justificación de 50 a 2.000 caracteres (se reusan si ya existían).
  10. Monto negativo: rechazo.
  11. Ruta 20: buscarEspacioDisponibleRutaOperacional reubica la hora en un hueco libre del día respetando bloqueos; si no hay, no se guarda.
  12. Otras rutas: validarSolapamientoCitaRemoto(inicio, duración, ruta, idPropio), que lee el día del servidor (getDocsFromServer), nunca de la caché ("una foto vieja aprueba una doble reserva").
  13. Teléfono nacional o extranjero válido.
  14. Cliente: crear o actualizar nombre.
  15. Coordenadas dentro de Costa Rica. No se puede guardar confirmada sin GPS. Provincia, cantón y distrito se recalculan del punto.
  16. Avisos humanos antes de leer la cita fresca: movimientos ajenos y especiales alistados. Mientras alguien lee un modal el técnico puede marcar ingreso; si la base ya se había leído, eso se pisaba.
  17. leerCitaFresca(id). Desde aquí no hay más esperas humanas hasta el commit.

Regla de solape (idéntica en cliente, servidor y Optimus):

choca(a, b) = a.inicio < b.fin && a.fin > b.inicio
   en el mismo día operacional, la misma ruta normalizada,
   ignorando canceladas y Ruta 20; duración por defecto 60

Lógica del monto al guardar (lo menos obvio):

montoGuardado = base.metadata.monto          // valor y tipo crudos
intacto = !tocoMonto && !tocoServicios && montoGuardado !== undefined
nuevo   = entero(campo) o 0
cambia  = !intacto && Number(montoGuardado || 0) !== nuevo
metadata.monto = montoGuardado               // el batch NUNCA lleva el monto nuevo
commit
si cambia: aplicarCambioMontoCita(id, montoGuardado, nuevo)   // va por la función del servidor (5.4)

Lección: el monto se formateaba al cargar y se releía al guardar; en citas viejas con texto o decimales eso cambiaba el tipo y la regla lo tomaba como cambio de plata, rebotando con "permisos insuficientes" aunque solo se hubiera corregido un comentario.

Armado de metadata:

  • Parte de la metadata fresca del servidor y le aplica los cambios del formulario.
  • Si la ruta deja de ser 20, borra los cuatro campos de cancelación (si no, una segunda cancelación reusaba el motivo viejo y el panel la contaba como revisada).
  • confirmada24_48: si el check no cambió se conserva el objeto con su fecha (evita historial espurio).
  • Calidad de venta: quien no tiene permiso guarda los valores frescos. Bug que lo motivó: un modal abierto antes de la marca de las 3:30 p. m. traía la venta limpia y al guardar borraba la marca.
  • metodoPago: si venció el plazo de arqueo, se conserva el guardado.
  • Se preservan si existen: reporte, coordenadas_corregidas, horas y GPS del técnico, precio_final_rutero, imagenes, modalidad_cita_creada, confirmacion_vencida, excluidos.

Escritura: buildHistorialEdicion(base, datos) → limpiar indefinidos → índices → un writeBatch con update(citas/id) y set(extras/historial, {historial: arrayUnion(...)}, merge). Después: monto por la función, registro de movimiento ajeno, nota de especiales, cerrar modal, recargar y recalcular traslados de la ruta vieja y la nueva.

5.2 Crear una cita (guardarNuevaCita)

  • Id reservado al abrir el formulario (doc(collection(db,'citas'))) y escritura con setDoc: una doble ejecución escribe el mismo documento.
  • Mismas validaciones que editar, sin la regla de 7 días; la fecha pasada solo gerencia y sin tolerancia.
  • modalidad_cita_creada del formulario: drag_and_drop por defecto, optimizador_algoritmo (Compás y Optimus), mapa_logistica, desde_cotizacion, excepcion_ruta_hora.
  • Vista previa del pantallazo de Ventas antes de guardar (si la cita califica).
  • Historial inicial del monto: entrada metadata.monto con valor anterior vacío y origen: 'creacion_cita' (el 88 % de las citas de un mes no tenía ninguna entrada sobre su monto). Si esa escritura falla no se aborta la creación.
  • Se suben los adjuntos y el pantallazo de confirmación que esperaban el id.

Recurrencia:

  • Mensual (n-ésimo día de la semana o día fijo): 11 copias. Bimensual: 5 copias. Manual: hasta 12 fechas.
  • "N-ésimo día de la semana": si la quinta semana no existe, cae en la cuarta.
  • Cada fecha valida solape; las que chocan se omiten.
  • Escritura en lotes de 400 por writeBatch; antes iban una por una y un fallo a mitad dejaba la serie incompleta sin aviso. Al final se informa cuántas se crearon, omitieron y fallaron.

5.3 Clonar una cita

clonarCitaEn(cita, "AAAA-MM-DDTHH:MM", ruta, extra). Desde el 30-oct-2026 el botón Clonar lleva a Compás (compas.html?clonar=<id>) para elegir día, hora y ruta por cercanía, y vuelve a la agenda a crear la copia.

metadataParaCopiaCita(origen):
  base = {estado:'agendada', confirmada24_48:{false,null}, imagen_confirmacion:null, metodoPago:''}
  copiar todo metadata menos CAMPOS_NO_CLONABLES:
    confirmación, método de pago, factura, historial, ultima_edicion,
    encuesta y su justificación, ingreso/salida/GPS, reporte, coordenadas_corregidas,
    precio_final_rutero, imagenes, campos de cancelación, whatsapp_coordinacion,
    campos de cotización
  • Teléfono extranjero se conserva; recortar a 8 dígitos inventaba un número nacional.
  • Valida solape salvo en Ruta 20. Escribe creado_por y fecha_creacion, lo que habilita al creador a ajustar el monto durante 24 h.

5.4 Monto protegido

Desde el navegador, nadie que no sea gerencia cambia metadata.monto (regla de Firestore). Los demás pasan por la función HTTP cambiarMontoCita (POST, empleado autenticado), con body {citaId, monto, motivo}.

Reglas de entrada: citaId de 1 a 128 caracteres, monto entero mayor o igual a 0, motivo recortado a 300 caracteres. Si el monto ya es ese, 400.

evaluarPolitica(usuario, cita, nuevo):
  si gerencia                                     → permitido ("gerencia")
  si nuevo == 0 y tiene permiso anular_monto_cita:
      si inicio de la cita > ahora                → permitido ("anular_monto_cita")
      si no                                       → 403 "anular después de la hora es de gerencia"
  si es su venta (creado_por == yo o yo ∈ vendedores):
      si inicio + 18 h > ahora o fecha_creacion + 24 h > ahora → permitido ("ajuste_mi_venta")
      si no                                       → 403 "la venta ya cerró"
  si no                                           → 403
transacción:
  metadata.monto = nuevo (y monto en la raíz si existía el espejo)
  metadata.historial arrayUnion(extracto), metadata.ultima_edicion
  extras/historial arrayUnion(entrada completa con motivo, origen 'cambiarMontoCita', regla)
  citas_monto_log/{auto} = {citaId, antes, despues, usuario, uid, motivo, regla, fecha_dia, creado}
  • anular_monto_cita es un permiso propio que no se hereda. Motivo: en un mes hubo anulaciones entre 21 y 28 días después de la cita, patrón de cobrar en efectivo y borrar la venta.
  • citas_monto_log se lee con reportes_gerenciales o gerencia; escritura solo servidor.
  • El cliente tiene un respaldo que escribe directo si la función no responde, marcado para retirar; con la regla actual solo funciona para gerencia.

Reglas de actualización de citas (resumen):

allow update: empleado
  && (no toca metadata.monto || gerencia)
  && (no toca precio_final_rutero || cobrandoElMismoDia || gerencia)   // inicio + 18 h > ahora
  && (no toca creado_por ni fecha_creacion || gerencia)
  && (no toca calidad de venta || gerencia o usuaria autorizada)
allow create: empleado && (creado_por es quien escribe y fecha_creacion == request.time || gerencia)
allow delete: false
  • cobrandoElMismoDia existe porque el técnico cobra en casa del cliente después del servicio; cerrar ese campo rompía la operación y lo detectó el emulador de reglas.
  • Discrepancia conocida: la pantalla considera gerencia solo departamento === 'gerencia'; las reglas también aceptan cargos que contengan "gerenc/gerent".
  • Advertencia: no usar match /{sub=**} dentro de citas; ese comodín también calza el documento de la cita y su write anulaba las restricciones del monto.

5.5 Historial y auditoría

buildHistorialEdicion(anterior, nuevos):
  comparar raíz (telefono, inicio_gmt6 por milisegundos, duracion_minutos)
  y cada clave de metadata salvo historial y ultima_edicion
  igualdad por JSON con claves ordenadas
  entrada = {usuario, timestamp (texto es-CR), campo, valorAnterior, valorNuevo (cortados a 80),
             valorAnteriorRaw, valorNuevoRaw}         // Timestamp como {__ts: ms}
  • cita-historial.js: desde sep-2026 el historial completo vive en citas/{id}/extras/historial porque era la mitad del peso de cada cita y el costo de Firestore se cobra por transferencia. En la cita queda un extracto: inicio_gmt6, duracion_minutos, metadata.ruta, metadata.servicios, metadata.productos y todo campo con "monto" o "precio_final" (sin los "manual" ni "pesos").
  • cargarHistorial: une extras con el extracto y deduplica por campo|usuario|timestamp|valorNuevo. Si la lectura falla lanza error: antes devolvía vacío y revertir guardaba una lista vacía encima del historial real.
  • Regla de extras/historial: crear para empleados; actualizar solo si la lista nueva contiene toda la vieja (hasAll); borrar nunca.
  • Revertir: toma el valor crudo (se niega si el texto viene recortado con "…"), valida solape si es hora, duración o ruta, agrega una entrada revertido:true, escribe por ruta de campo, recalcula índices y, si el campo es el monto, pasa por cambiarMontoCita. Lección: revertir una hora escribía texto en inicio_gmt6 y la cita desaparecía del cronograma; por eso se guardan los valores crudos.
  • Modal "Historial del día operacional": une cambios del ruteador con eventos de citas (creación, ruta, hora, duración) y marca con luna lo hecho entre 7 p. m. y 5 a. m.

5.6 Cancelar (mover a Ruta 20)

Caminos: modal de edición, cita nueva directa en Ruta 20, arrastre a la columna 20, migración en lote (una justificación para todas), importador CSV.

  • Motivos (lista cerrada): "Cliente cancela", "Vendedor cancela", "Gerencia cancela" (varias variantes con nombre), "Pendiente de reagendar", "Cliente no aceptó su visita de garantía", "Cliente no contesta para su visita de garantía", "Otro - Especifique".
  • Justificación de 50 a 2.000 caracteres.
  • Ruta 20 no valida solapamiento: busca un hueco libre del día para no apilar.
  • El arrastre escribe fecha_cancelacion: Timestamp.now(); el modal no (inconsistencia conocida).
  • Revisión de cancelaciones (botón flotante, solo gerencia): lee el mes, separa pendientes de revisadas y exige un texto de 50 caracteres que se guarda en metadata.revision_cancelacion {texto, usuario, fecha}.
  • Efectos de servidor: si el motivo es uno de los dos de garantía, se cierra la garantía vinculada (cliente_desiste o cliente_no_contesta). Los triggers de solape, cartera, garantías y llamada ignoran Ruta 20.

Botón flotante "Sin ruta": citas del mes con ruta vacía o "Sin asignar", con conteo propio y monto. reporte_sin_ruta.html (sección 12.6) permite anular su monto.

5.7 Citas ficticias (venta en ruta)

Es una venta que el técnico hace en campo (por ejemplo, el cliente pide un servicio extra), no una visita.

crearCitaFicticia(citaOrigen, descripcion, precio):
  candado contra doble toque          // cada duplicado infla ventas
  exigir descripción y precio > 0
  inicio = fin de la última cita de esa ruta ese día + 30 min (9:00 si la ruta no tiene citas)
  duracion_minutos = 30
  metadata = {estado:'agendada', confirmada24_48: copia de la cita origen, monto: precio,
              vendedores: técnicos del ruteador de esa ruta, servicios:[{id:'ficticio', nombre, cantidad:1, precio}],
              canal:'Ficticio', es_ficticio:true, cita_origen_id, dirección y coordenadas copiadas}
  sin ingreso, salida, GPS ni reporte; no escribe creado_por

Regla: la plata cuenta, la visita no. Suma en ventas y comisiones y dispara el evento de compra; queda fuera de encuestas, puntualidad, recordatorios de reporte, tickets en rojo, refuerzo de garantía, alisto y conteo de visitas. Antes copiaba el GPS de la cita origen y los reportes de cumplimiento la contaban como una visita más.

5.8 Confirmación y sus plazos

Fuente única en el navegador: js/confirmacion-plazos.js. El servidor replica la misma regla.

turno(inicio) = nocturna si hora >= 16 o hora < 5 ; diurna si no
limiteConfirmacion(inicio):
  dia = fecha de inicio
  si hora < 5: dia -= 1          // la madrugada es de la jornada anterior
  dia -= 1                       // vence el día antes de la jornada
  return dia a las 15:30
estaConfirmada(cita) = confirmada24_48.confirmada
   && (si ruta 11..17 y inicio >= 10-sep-2026: imagen_confirmacion presente)
evaluarCita → {estado: confirmada | a_tiempo | vencida, faltaFoto, exigeFoto, minutosAlLimite, limiteMs}
  • Historia: hasta el 16-oct-2026 había dos plazos (12 MD diurnas, 4 p. m. nocturnas); desde el 17-oct-2026 uno solo a las 3:30 p. m. del día anterior.
  • Ventana para marcar: se abre 3 días operacionales antes. El botón funciona como interruptor y solo se escribe al pulsar Guardar. Requiere GPS.
  • Pantallazo de confirmación: una sola imagen, aparte de los adjuntos ("es un documento de control"), JPEG en citas/{id}/confirmacion/{ts}.jpg; se escribe solo metadata.imagen_confirmacion.
  • Plantilla de confirmación por WhatsApp: dos tipos, casas (avisa de más o menos 15 min) y locales (hora exacta), con plantillas aprobadas por la plataforma de mensajería y respaldo si la plantilla falla. Guarda cnf_tipo y nombre_saludo. Enviar la plantilla no marca la cita como confirmada. Lección: se mandaban 8 dígitos y el servidor anteponía el código nacional, así que un cliente extranjero terminaba en el número de otra persona.

Servidor (functions/confirmacion-vencida.js), zona Costa Rica, 540 s, 512 MiB:

Función Horario Acción
confirmacionAviso12 0 12 * * * avisarPlazo: deja en alertas_confirmacion/{dia}_1530 la lista de pendientes de mañana y avisa por WhatsApp al grupo de coordinación de ventas (una vez por clave, candado transaccional)
confirmacionAviso14 0 14 * * * Segundo aviso igual
confirmacionVencida1530 30 15 * * * aplicarPlazo: marca las no confirmadas como venta no comisionable
confirmacionVencidaNow HTTP, solo gerencia accion = ver | avisar | aplicar, WhatsApp opcional
citasDelPlazo(hoy):
  citas con fecha_dia entre mañana y pasado mañana
  descartar: canceladas, Ruta 20, sin hora, cuyo límite no sea exactamente hoy 15:30,
             teléfonos en confirmacion_no_necesaria
  motivo: 'sin_check' o 'falta_foto'
aplicarPlazo():
  falta = limite - ahora
  si falta > 120 s: salir ("todavía no vence") ; si 0 <= falta: esperar falta + 1 s
  por cada cita no confirmada que no esté ya marcada hoy:
     subir constancia SVG a calidad_venta/confirmacion_vencida/{plazo}_{citaId}.svg
     batch: venta_no_calidad=true, justificación (concatenada si ya había una manual),
            evidencias += constancia, confirmacion_vencida={plazo, limiteMs, revisadoMs, motivo, avisada, constancia},
            ultima_edicion = "sistema (confirmación vencida)", historial
  alertas_confirmacion/{id}.aplicado = {at, totalCitas, marcadas[], errores[]}
  WhatsApp "aplicado" una vez (lista o "al día")
  • En la agenda (agenda-aviso-confirmaciones.js): listener sobre alertas_confirmacion/{hoy}_1530; todos ven las pendientes de todos (decisión de "compañerismo"), las propias primero; cada 60 s se reevalúan y se quitan las ya confirmadas; después de las 3:30 muestra "quedaron sin comisión".
  • El ruteo de quién responde por cada vendedor en el aviso está fijo en el código (por ejemplo, los vendedores de calle reportan a su encargada).
  • Según la documentación del proyecto, a fines de octubre la regla marcó cerca del 6 % de las citas y se revirtió la comisión de las marcadas (confirmacion_vencida.revertida); el schedule sigue activo.

5.9 Plazos de arqueo y encuesta (días hábiles)

siguienteHabil(d): d + 1 mientras sea domingo o feriado (lista fija de feriados por año)
plazoArqueo(cita)   = siguienteHabil(dia de la cita) a las 10:00   // método de pago editable hasta aquí
plazoEncuesta(cita) = siguienteHabil(plazoArqueo) a las 10:00      // enlace de encuesta o justificación

El corte de arqueo es fijo porque contabilidad copia el TSV del día a las 10:00. Lección: citas de un viernes vencían un sábado feriado y se perdía la comisión sin forma de evitarlo.

5.10 Movimientos de citas ajenas

agenda-movimientos-ajenos.js se engancha antes y después de guardar o soltar.

No avisa si: es gerencia; la cita tiene reacomodo_libre; la cita no tiene vendedores o el usuario es uno de ellos; es la encargada de ventas de calle y todos los vendedores son técnicos; no cambió la hora (60 s o más) ni la ruta; ni el día de origen ni el de destino es hoy o mañana.

Si aplica: modal con motivo (autorizado_vendedor, cliente_pidio, gerencia_autorizo, otro), texto de al menos 15 caracteres y casilla de responsabilidad. Después del guardado (nunca revierte) escribe movimientos_ajenos/{auto} con dueño, quién movió, motivo, de{inicioMs, ruta, fechaDia}, a{...}, estado:'pendiente', una entrada de historial y una tarea en notas_mentales para cada vendedor. El vendedor marca autorizado o no_autorizado; gerencia revisado. La válvula "Libre/Protegida" la cambia solo el vendedor o gerencia. No lo disparan la migración en lote ni las cancelaciones.

Lecciones: el candado del aviso quedaba en verdadero y bloqueaba todo movimiento hasta recargar; el focus trap de Bootstrap robaba el foco del textarea.

5.11 Productos especiales ya alistados al mover

Caso real: bodega alistó producto para el técnico de una ruta y minutos después movieron la cita a otra ruta. Hay un portero (avisarEspecialesAlMoverCita) al inicio de cada camino que mueve citas:

puertas (de la más barata a la más cara):
  1. cambió la ruta y el origen no es "Sin asignar"
  2. la cita es de hoy o mañana
  3. la cita requiere productos (asociados al servicio + sueltos)
  4. hubo movimientos_inventario hacia las bodegas especiales de los técnicos de la ruta origen
     en el día operacional, con ítems de esta cita (sin anulados)
si pasa: avisa, copia un mensaje para bodega y, tras confirmar el guardado (válido 5 min),
  escribe ruteador/{fecha}_{Ruta-N}.notasBodega[citaId] = {cliente, hora, rutaOrigen, rutaDestino,
  bodegas y técnicos de origen y destino, items[{productoId, nombre, cantidad}], avisadoPor, en, resuelto:false}

No mueve inventario; despacho resuelve. El caso simétrico (cambian los técnicos de una ruta) lo maneja agenda-reasignar-especiales.js: "el inventario pertenece a la ruta, no al técnico". Para hoy mueve el saldo real; para otro día solo lo alistado para ese día (bug: editar una ruta futura vació la bodega del día en curso). Usa una bodega transitoria mientras la ruta no tiene técnico.

5.12 Migración de rutas

Mueve todas las citas de una ruta origen a una destino dentro del día operacional. Destino Ruta 20 pide una justificación única. Aviso de especiales en bloque (si se cancela uno, no se migra ninguna). Cada cita: lectura fresca, escritura por rutas de campo (metadata.ruta y cancelación, con deleteField si sale de la 20) y historial. No valida solapamiento en el destino ni dispara movimientos ajenos. Lección: reemplazar el mapa borró ingreso, GPS, reporte y fotos de todas las citas migradas.

5.13 Triggers de servidor sobre citas

Trigger Evento Qué hace
citaOverlapCheck onCreate Ignora Ruta 20. Compara contra las citas del mismo día y ruta no canceladas; si choca escribe _overlap_detected, _overlap_with[], _overlap_checked_at. No bloquea; la ficha muestra un banner
stampClienteOnCita onWrite Mantiene analitica_clientes/{tel} con incrementos de total_facturado y total_citas según el delta (si cambia el teléfono resta en el viejo y suma en el nuevo); re-estampa la conversación del CRM
metaCapiOnCita onWrite Eventos de conversión al anunciante con ids deterministas: Lead al crear, Schedule cuando se confirma, Purchase cuando el monto pasa de 0 a positivo, Upsell por la diferencia. Solo si la conversación vino de un anuncio. La ficticia no manda Lead ni Schedule
llamGCompraOnCita onWrite Si la cita tiene monto y el lead tenía reloj de llamada obligatoria, marca la conversación como compra
garantiaRefuerzoOnCita onWrite Cierre de garantía por cancelación, tickets en rojo y vínculo automático de visitas de garantía (sección 9.8)
recuperadorCarteraOnCita onWrite Saca al cliente de la lista de recuperación si tiene cita futura o ya facturó
portalPushAlCerrarReporte onWrite Empuja el reporte al portal del cliente (sección 9.9)
ventasAutoEnviar onCreate Envío automático al grupo Ventas a los 3 minutos (sección 11.3)

6. Ruteador: técnicos, vehículos y convocatoria

6.1 Propósito y pantallas

Cada ruta de cada día necesita técnicos, un vehículo y una hora de convocatoria (hora a la que los técnicos se presentan en la central). La asignación se hace en el cronograma de la agenda (modal "Asignar técnicos y vehículos" y arrastre de recursos sobre la cabecera de cada ruta). La página ruteador.html no asigna: es un reporte de eficiencia por ruta (permiso ruteador).

Lo usan coordinación de operaciones y gerencia; los técnicos solo lo leen (ruta.html decide "cuál es mi ruta" con este documento).

6.2 Colección ruteador

Un documento por ruta y día con id determinista {fecha}_{Ruta-N} (por ejemplo 2026-10-31_Ruta-7); la ruta se recupera del id si falta el campo. Todas las escrituras son setDoc(..., {merge: true}).

Campo Tipo Notas
fecha AAAA-MM-DD Consulta principal where('fecha','==',dia): una lectura reemplaza 21 lecturas por id
ruta texto
tecnicos[] usernames Normalizados sin duplicados ni vacíos
vehiculos[] nombres de vehículo En la práctica uno
horaConvocatoria "HH:MM" o null Alias viejo hora_convocatoria
bloqueoHorario arreglo de hasta 2 {inicio, fin, descripcion?, ayudaRuta?, creadoPor?} Franjas sin citas (almuerzo, taller, reunión) o de apoyo a otra ruta; antes era objeto suelto, se leen ambas formas
zona id de atlas_zonas Zona geográfica asignada
traslados mapa Caché de tiempos de manejo (6.7)
tecnicosAuto bool Ya se pusieron los técnicos fijos
vehiculoPrevio, vehiculoPrevioEn, sinSombraVehiculo varios "Sombra" del vehículo tras liquidar (6.9)
turno diurna, nocturna, rural Lo fija despacho para ese día
despacho, despachoAutoOff mapa, bool Marca de salida
checklistRuta, checkLiqVehiculo, checkLiqPersonal, notasBodega, revisadosDevueltos, sobrantesDejados, split mapas Los escriben la app del técnico y despacho

Colecciones hermanas:

  • ruteador_dias/{fecha}: caché del reporte de eficiencia y candado de convocatoria (convocatoriaBloqueada, candadoPor, candadoEn).
  • ruteador_historial: un documento por guardado que cambió algo.
  • atlas_zonas: {nombre, color, poligono[]}.
  • configuracion/rutas: turnos{ruta: diurna | nocturna | rural}, turno fijo de cada ruta.

6.3 Guardado con historial

Todos los caminos de escritura pasan por un único helper:

guardarRuteadorConHistorial(id, payload, origen):
  antes = leer ruteador/id (si falla, sigue)
  setDoc(ruteador/id, payload, merge)          // el guardado nunca depende del historial
  try:
    cambios = para campo en [tecnicos, vehiculos, horaConvocatoria, bloqueoHorario, zona, turno]:
                si payload[campo] definido y norm(antes) != norm(payload): {campo, antes, despues}
    si cambios: addDoc(ruteador_historial, {fecha, ruta, usuario, usuario_nombre, origen,
                 esNuevo, cambios, tieneVehiculos, fechaISO})
  catch: solo registrar en consola
  • Orígenes: modal, arrastre, arrastre-hora, apv (asignación automática), masivo, zona, tecnico-fijo, despacho.
  • Bandera tieneVehiculos (desde 10-sep-2026): Firestore no filtra por el contenido de un arreglo de objetos y un día activo tenía unos 5.000 documentos de historial (cerca de 6 MB), de los cuales solo unos 30 tocaban vehículos.
  • Lección: escribir la zona en cada repintado generaba miles de documentos de historial al día en pares simétricos (zona → null → zona); se dejó de escribir si no cambió.

6.4 Reglas de técnicos y vehículos

  • Nadie en dos rutas el mismo día. validarDuplicadosRuteador lee el día completo y devuelve conflictos. El aviso resuelve: "Quitar de Ruta X y guardar acá" ejecuta quitarRecursoDeRutas y vuelve a guardar (pedir "quitalo primero de la otra ruta" dejaba a la persona rebotando entre modales).
  • quitarRecursoDeRutas(fecha, {tipo, valor}, {rutas?, excepto?}) busca contra el servidor en todas las rutas del día. Con caché el arrastre no veía la segunda ruta y el recurso quedaba duplicado.
  • Cola global de arrastres: una promesa encadenada procesa un arrastre a la vez. Bug del 22-oct-2026: dos arrastres seguidos corrían en paralelo y un vehículo quedó en dos rutas.
  • Técnicos disponibles: departamento de fumigación con visibleEnRutas != false; los que están de vacaciones salen en rojo con etiqueta VAC.
  • Técnicos fijos de las rutas 11 a 15: cada ruta se asocia a un técnico por la etiqueta de su gafete y se aplica solo de hoy en adelante, de lunes a viernes, si la ruta está vacía y sin tecnicosAuto, y nunca a quien ya esté en otra ruta. Si la etiqueta coincide con dos empleados no se asigna ("adivinar es peor que no ponerlo").
  • Botones masivos: mover todos los técnicos (o vehículos) a sin asignar.
  • Bloqueos de horario: máximo 2 por ruta. Con ayudaRuta, las citas de la ruta ayudada que se solapan con la franja se copian como "Apoyo" en la columna de quien ayuda (dibujadas solo dentro de la franja, debajo de las propias). Solo personal autorizado (permisos.habilitado o gerencia) borra una franja; si otro la vacía, se restaura en silencio.
  • Drag and drop de recursos: dataTransfer, con respaldo en variables globales que se limpian a los 500 ms. Soltar sobre una fila: turno de cola → candado → confirmación de día pasado → quitar el recurso de todas las rutas → escribir destino → barrido final excepto el destino. Soltar en "Sin asignar" lo desasigna; doble clic sobre la insignia lo quita. Soltar sobre una cita asigna a la ruta de esa cita (antes no lo sacaba de su ruta anterior). El candado del drop se libera siempre (salidas tempranas lo dejaban puesto y esa zona quedaba muda hasta recargar).

6.5 Hora de convocatoria y candado

  • Línea horizontal en la columna. Sin hora: gris punteada a las 5:30. Con hora: en el color de la ruta, con "Xh Ymin hasta la 1.ª cita".
  • Arrastre de la línea redondeado a 15 min; bloqueado si el día ya pasó, si la hora ya pasó hoy o si el día tiene candado.
  • Candado del día (ruteador_dias.convocatoriaBloqueada): con él no se arrastran línea, técnicos ni vehículos; todo se cambia desde el modal. Motivo: "la línea se movía sin querer con un clic". Al quitarlo se advierte que los cambios se avisan a gerencia, que avisa a los técnicos.
  • Apertura de bodega: para cada técnico su convocatoria más temprana; la bodega abre a la menor. Se copia como texto para WhatsApp.
  • La hora de convocatoria alimenta la alerta de llegada tardía (11.5) y el reporte de puntualidad (12.5).

6.6 Guard de días pasados

_confirmarModRuteadorPasado(fecha): hoy nunca se frena (mover gente el mismo día es normal). Para días anteriores se pide resolver una suma a + b con números de 2 a 9, porque cambiar una ruta pasada reescribe la asistencia. Un movimiento entre dos rutas pregunta una sola vez. El permiso vale para un guardado. Lecciones: Bootstrap robaba el foco al campo de respuesta; un clic afuera cancelaba en silencio; cancelar ahora se informa.

6.7 Traslados entre citas (tiempos de manejo)

En el cronograma, por ruta se arman los tramos: salida (central → primera cita con GPS), entre citas consecutivas y regreso (última → central), incluyendo las de apoyo.

  • Se consultan a un servicio de direcciones de mapas agrupando tramos contiguos en cadenas de hasta 11 puntos (origen, 9 intermedios, destino) sin optimizar el orden: pasar de 10 intermedios u optimizar cambia a una tarifa más cara.
  • Días pasados no se recalculan.
  • Se guarda en ruteador.traslados = {citasHash, salidaCentral, entreCitas[], regresoCentral, horaFinEstimadaMinutos}; citasHash (id, lat, lng, inicio, duración) marca si quedó desactualizado; hay recálculo incremental por segmentos.
  • Se dibuja: salida máxima de la central (inicio de la primera cita menos traslado), saldo entre citas (tiempo libre menos manejo, verde o rojo), llegada estimada (fin de la última + regreso + 15 min de descarga + 10 min de imprevisto) y jornada (llegada menos convocatoria).
  • Estos tramos medidos calibraron el modelo de manejo de Compás (12.1).

6.8 Asignación automática de vehículos

POOL = [vehículo 01 .. vehículo 08]           // flota de la empresa
para bloque en [rutas diurnas, rutas nocturnas]:     // un vehículo puede repetirse entre bloques
  rutas = bloque sin "Sin asignar", en orden numérico
  libres = POOL - vehículos ya usados por alguna ruta del bloque
  i = 0
  para ruta en rutas:
    si i >= len(libres): parar
    si la ruta no tiene citas o ya tiene vehículo: seguir
    asignar libres[i++]  (guardar con origen 'apv')

Es un reparto glotón por número de ruta, sin mirar zona ni kilometraje.

6.9 Sombra del vehículo

Cuando una ruta liquida, entrega su vehículo y otra ruta lo puede tomar; el ruteador deja de decir con qué carro anduvo.

sombraDeRuta(ruta, rt, sombrasDelHistorial):
  si rt.vehiculos no vacío: ''              // todavía lo lleva
  si rt.vehiculoPrevio: devolverlo          // apunte al liquidar
  si rt.sinSombraVehiculo: ''               // se lo quitaron, no lo entregó
  devolver sombrasDelHistorial[ruta]        // último valor no vacío en ruteador_historial del día

Caché por fecha de 2 minutos y deduplicación de pedidos: sin caché cada repintado bajaba cerca de 1 MB, "el grueso de lo que la gente sentía como agenda pegada". Se comparte entre agenda y despacho para no tener dos cuentas distintas.

6.10 ruteador.html: eficiencia por ruta

calcularDia(fecha):
  citas del día; excluir Ruta 20 y canceladas
  ficticias suman facturación pero no son parada, horas ni km
  por ruta: ordenar por inicio; hash = djb2(ids + lat/lng con 5 decimales)
    si cambió el hash: ruteo de calles (OSRM público) central → citas → central, timeout 9 s
       si falla: haversine * 1,35 a 30 km/h, marcado 'estimado'
  guardar caché en ruteador_dias/{fecha}
métricas por ruta: facturación, horas agendadas, km, horas de traslado, ₡/km, ₡/h de traslado (orden por defecto),
  ₡/cita, ₡/h agendada
bandera: valor más de 30 % por debajo de la mediana de 14 días (la propia si hay 5 datos o más, si no la global)

"Construir histórico" recalcula los días faltantes con 600 ms entre llamadas al ruteo.

6.11 Búsqueda de espacio por proximidad (heredada)

Herramienta vieja del mapa de la agenda, reemplazada por Compás y Optimus pero útil como referencia:

punto del cliente; citas de hoy a hoy+28 días; descartar la misma ubicación (< 10 m)
buckets: 0-3, 4-7, 8-14, 15-28 días; filtros por día de semana y turno
hayEspacio(antes|después) = hueco >= servicio + 2 * traslado (60 y 30 min por defecto) contra la cita vecina
rango: 0 si hay espacio y dist <= 5 km; 1 si hay espacio; 2 si no; luego por distancia; 10 por bucket

Su falla (solo miraba la cita vecina, no todas las del día) motivó Optimus y Compás.


7. Despacho y alisto

7.1 Propósito

despacho.html (permiso despacho) es el tablero de quien saca las rutas en la mañana y en la noche. Responde de un vistazo: quién ya inició turno, si la ruta recogió todo lo que necesita y si ya salió. Concentra además el alisto de productos especiales (de la bodega principal a la bodega especial de cada técnico), el cambio de vehículo, la liquidación de cierre, devoluciones y sobrantes, la bodega transitoria y la impresión de certificados y hojas de alisto.

alisto.html quedó como redirección a despacho desde el 26-ago-2026: el alisto en bloque es la función "Alistar especiales" de despacho.

7.2 Pantalla

  • Pestañas: Tablero, Servicios (catálogo con productos y herramientas asociados) y Herramientas (catálogo herramientas_alisto).
  • Filtro de turno: diurna (por defecto), nocturna, rural o todos.
  • Tira de fechas de 3 días atrás a 7 adelante; días con devoluciones sin revisar marcados en rojo.
  • Tarjeta por ruta: técnicos con estado de turno (sin encender, trabajando con minutos, terminado), las tareas, vehículo con botón de cambio, barra de liquidación, especiales, sobrantes, no recibidos, traslados entre rutas y notas de bodega.

Turno de una ruta:

turnoElegido = ruteador.turno (solo ese día) || configuracion/rutas.turnos[ruta] || rural para 18 y 19
si elegido es diurna o nocturna: usarlo
si algún técnico tiene bodega personal clasificada nocturna: nocturna
si la primera cita es a las 19 h o más, o antes de las 5 h: nocturna
si no: diurna

El valor por defecto no se guarda ("quedaría escrito como decisión de alguien").

7.3 Datos que lee

  • citas por fecha_dia y por rango de inicio_gmt6 del día operacional, deduplicadas, sin ficticias ni Ruta 20, en vivo.
  • ruteador en vivo.
  • work_sessions en vivo: un turno es vigente si arrancó desde las 5:00 de hoy y no lleva más de 2 h sin actividad (un turno no cerrado de ayer pintaba al técnico en verde y podía disparar el despacho automático).
  • empleados, bodegas, servicios, productos, herramientas, movimientos_inventario del día, stock_bodegas.

7.4 Tareas "Antes de salir"

Cinco tareas: datáfono, certificados, herramientas, especiales y vehículo.

Tarea Aplica si
Datáfono Alguna cita paga con datáfono
Certificados Alguna cita lleva certificados
Herramientas La ruta lleva herramientas (metadata.equipo más las del servicio, una por tipo, respetando exclusiones)
Especiales La ruta no es rural y requiere productos o ya tiene alisto; pendiente mientras falte alisto
Vehículo Nunca desde el 21-oct-2026 (bodegas de vehículo fuera de uso)

Una tarea está hecha si existe ruteador.checklistRuta[tarea] o si el técnico marcó todos sus renglones en ruta.html. Despacho solo lee esas marcas.

todoEnVerde(ruta) = tiene técnicos && ninguna tarea pendiente && todos con turno vigente
autoDespachar (solo el día operacional de hoy):
  si !rt.despacho && !rt.despachoAutoOff && todoEnVerde: rt.despacho = {por:'auto', auto:true, en:ahora}
quitar la marca a mano → despachoAutoOff = true

7.5 Agregación del alisto

acumularRequeridos(acum, metadataCita):
  para cada servicio de la cita:
     def = catálogo de servicios (por id o nombre)
     para cada productoAsociado no excluido:
        acum[productoId] += servicio.cantidad (1) * pa.cantidadPorUnidad (1)
  para cada producto suelto (legado): acum[id] += cantidad
requeridos[ruta] = suma sobre las citas de la ruta (sin ficticias ni Ruta 20)
yaAlistado[ruta] = suma, por técnico de la ruta, del NETO DEL DÍA cargado a su bodega especial
                   (entradas del día operacional menos devoluciones; lo gastado cuenta como alistado)
faltantes = productos con yaAlistado + 0,001 < requerido ; día pasado: cerrado

Lecciones: el saldo arrastrado no cuenta (una ruta salía como alistada porque al técnico le quedaba producto de antes); cuenta todo movimiento del día, no solo los marcados como pedido especial; corte por día operacional (la nocturna perdía su alisto); un traslado a la especial de otro técnico no se suma de vuelta.

planDeAlisto():
  para cada ruta con citas, no rural, en el turno filtrado:
     destino = bodega 'especial' activa de algún técnico de la ruta
     líneas = faltantes (con productoId; buscar por nombre mandaba dos veces el primero de dos homónimos)
repartirPorStock(): el saldo de la principal se reparte en orden de ruta; celdas apagadas envían 0
generarAlisto():
  releer especiales y stock (otro despachador pudo alistar)
  rehacer el plan; si alguna línea bajó, avisar y no generar
  por línea > 0: procesarMovimientoInventario({tipo:'transferencia', origen: principal, destino: especial,
     cantidad, motivo:'Pedido Especial', esPedidoEspecial:true, origenPantalla:'despacho',
     rutaOperativa, fecha: día a las 12:00})
  guardar la hoja solo si salió algo
  • La hoja de alisto es un tablero de 4 rutas por página que se convierte a PDF en el navegador y se envía por WhatsApp a bodega.
  • Los certificados se imprimen agrupados por ruta (registrándolos antes, ver 9.7).

7.6 Liquidación, vehículo y otras operaciones

  • Liquidada: todos los técnicos con reloj apagaron la app (su app no los deja apagar con la liquidación incompleta, ver 8.9). Días pasados se juzgan solo por lo anotado.
  • Al liquidar hoy se vacía vehiculos y se escribe vehiculoPrevio; despacho lo repite por si el técnico ya cerró el teléfono.
  • Cambiar vehículo: primero quitarlo de la otra ruta, después ponerlo aquí (si falla la segunda escritura queda sin ruta, visible, nunca en dos). Solo de hoy en adelante.
  • Quitar un ítem de una cita: se escribe metadata.excluidos o se reduce metadata.productos, y se devuelve a bodega solo lo que sobra:
devolver = max(0, min(cantidadQuitada, tiene - (pide - cantidadQuitada), tope))

Antes se devolvía siempre y "el faltante se movía, no se resolvía".

  • Bodega transitoria (se crea sola): sala de espera del alisto de una ruta sin técnico.
  • Cargas del día con opción de revertir.

7.7 Inventario que toca la operación

  • bodegas {nombre, tipo, empleadoId, vehiculoId, activa, clasificacionRutero?}; tipos principal, especial (una por técnico), rutero (personal), herramientas, apv (vehículo, fuera de uso), transitoria.
  • stock_bodegas/{bodegaId}__{itemId} con stockActual.
  • Toda escritura de stock pasa por la función movimientoInventario (transacción en dos fases, unidades enteras, stock suficiente, firma del servidor, permisos por tipo de movimiento y por participación en la bodega). Detalle en el capítulo de inventario.
Tipo Efecto
compra, reversion, ajuste_entrada destino + cantidad
devolucion_compra, gasto, ajuste_salida origen − cantidad
transferencia, devolucion origen − cantidad, destino + cantidad

7.8 Traslado Flash y Traslado Exprés

Pantallas de bodega para mover producto rápido con lector de código de barras:

  • Traslado Flash: dos pantallas. Primero se escanea la etiqueta de la bodega destino (código corto de 7 dígitos que empieza con 9, o el id crudo); luego cada producto. Una bodega especial pregunta antes para cuál ruta es el alisto; ese alisto se imputa al día operacional de la ruta a las 12:00 y se marca como pedido especial. Sonido agudo si entró, grave si no. Historial de 48 h con eliminar. Los productos inactivos se pueden mover (apagado significa "ya no se compra", no "no se puede sacar").
  • Traslado Exprés: tres estaciones (de dónde, qué, a dónde), destinos agrupados por tipo y turno, opción de gasto a una cuenta. Un lector escribe en ráfaga (menos de 40 ms por tecla); se espera a que termine antes de entrar al producto. Los códigos se comparan sin espacios ni mayúsculas y, si son solo dígitos, sin ceros a la izquierda (un EAN-13 leído como UPC-A pierde el cero).
  • Las bodegas de vehículo no se ofrecen desde el 21-oct-2026.

8. Vista del técnico (ruta.html)

8.1 Propósito y arquitectura

Aplicación web móvil del técnico fumigador: ver su ruta del día, iniciar y cerrar turno, recibir lo alistado, marcar ingreso y salida en cada cita con GPS, llenar el reporte de servicio con fotos y audios, cobrar y liquidar su inventario. Permiso ruta. También la abren oficina y gerencia para mirar o corregir.

  • Una sola página grande con módulo JS; Leaflet con teselas libres para el mapa.
  • Caché offline persistente de Firestore (IndexedDB, varias pestañas) inicializada antes que cualquier otro módulo, porque una vez arrancado Firestore no se puede cambiar la caché. Sin IndexedDB cae a memoria.
  • El inventario se carga con import() al primer toque: abrir la ruta no debe costar lecturas de bodegas.
  • Toda escritura por ruta de campo y con historial (CitaHistorial.prepararHistorial deja extracto en la cita y lista completa en extras/historial en el mismo batch).

8.2 Qué ruta le toca

cargarCitas(fecha) = citas where fecha_dia == fecha, ordenadas por inicio
getMiRuta(fecha):                       // memo de 60 s
  docs = ruteador where fecha == fecha
  usuario = usuario de la vista ("ver como" si está activo)
  ruta = doc cuyo tecnicos[] contiene usuario (comparando con trim)
  normalizar a "Ruta N"
  • Se lee el día completo y se compara a mano: con array-contains un username guardado con un espacio al final no aparecía y el turno arrancaba como "oficina".
  • Selector con las rutas que tienen citas (sin Ruta 20) más la propia. Sin ruta: "Para el día X aún no has sido asignado a ninguna ruta".
  • noEsMiRuta = soloVerComo || !miRuta || miRuta != rutaEnPantalla: franja roja "NO es tu ruta asignada" y botones de alisto y liquidación convertidos en etiquetas. Bug corregido: con miRuta nulo la expresión vieja decía "sí es mía" y cualquiera quedaba con cobros y vehículo ajenos.
  • "Ver como" (permiso suplantar): muestra la ruta de otro empleado en solo lectura; todas las escrituras quedan desarmadas porque se firmarían con el uid de quien mira.

Tiempo real: tres listeners (ruteador del día, citas del día de la ruta y sus rutas de apoyo con metadata.ruta in [...], stock de las bodegas de la ruta hasta 10). Se ignoran el primer snapshot y las escrituras pendientes; con un modal abierto el repintado se aplaza. Una cita que sale de la ruta se quita (antes se le podía marcar ingreso). citaVigente(cita) devuelve siempre el objeto vivo (bug: con el detalle abierto el reporte se guardaba en un objeto y la salida leía otro sin reporte).

Citas de apoyo: si un bloqueo del ruteador tiene ayudaRuta, las citas de la ruta ayudada que tocan la franja aparecen como copia recortada a la franja; su ingreso y salida se guardan en metadata.apoyos.<Ruta_N>.* sin pisar los de la ruta dueña; no piden reporte ni cobro.

Avisos:

  • Al abrir: citas con hora pasada sin ingreso y terminadas sin salida (una vez por combinación).
  • Navegar a la siguiente cita queda bloqueado si una anterior no tiene ingreso o salida.
  • Cercanía: watchPosition más lectura cada 60 s; a menos de 500 m de la siguiente cita sin ingreso y sin modal abierto, pregunta "¿Querés marcar ingreso?" (con un modal abierto se espera: antes hacía marcar la cita equivocada).
  • Rastro de ubicación en dim_location como máximo cada 55 s.

8.3 Turno (iniciar y apagar)

work_sessions {username, userId, firebaseAuthUID, startTime, endTime, status:'working', gpsStart, distanciaInicio, inicioTardanza?, minutosTardeConvocatoria?}.

verificarCentral(accion):            // desde el 19-oct-2026, al iniciar y al cerrar
  gps = posición con alta precisión, timeout 15 s, sin caché
  dist = haversine(gps, CENTRAL)
  margen = min(gps.accuracy, 150)
  si dist - margen > 300 m: error "Estás a X m de la central"
  • Tardanza: si hay convocatoria y el turno se inicia más de 5 min después, se anota.
  • Sesión colgada de otro día: se cierra con su última señal y se abre la de hoy. Doble toque: se reconsulta justo antes de crear.
  • Tipo de turno: sin ruta, oficina sin inventario; rural, sin vehículo; resto, ruta (las bodegas de vehículo están apagadas desde el 21-oct-2026).
  • Técnicos de fumigación sin turno pasada la convocatoria: pantalla bloqueada con enlace al registro de horas.
  • Apagar se bloquea si hay faltas de liquidación (8.9) o si toca la revisión semanal de EPP y herramientas. Citas sin método de pago o, en rutas 11 a 19, sin condición, solo avisan.

8.4 Alisto recibido

ruteador.checklistRuta[item] = {por, en, ok, recibidas} con merge. Solo con la app encendida y desde 10 minutos antes de la convocatoria. Un especial que no llegó queda en espFaltan para que despacho decida; el técnico no mueve ese inventario.

8.5 Ingreso a la cita

guardarHoraIngreso(cita):                      // la cita viaja por parámetro, no la "abierta" global
  si ficticia: rechazar ("una venta en ruta no lleva ingreso ni salida")
  si ya hay ingreso: confirmar actualización
  valor = ahora como texto es-CR               // la hora se guarda como TEXTO
  gps = capturarGPS()
  puntos = coordenadas de la cita + coordenadas_corregidas + coordenadas de cada servicio
  dist = distancia al punto más cercano
  si (dist - accuracy) > 300 m: pedir causa
       "Olvidé marcar en el punto" → culpa_tecnico: true
       "El punto de la cita está mal (error de ventas)" → culpa_tecnico: false
  B = 'metadata.' o 'metadata.apoyos.<Ruta_N>.'
  batch: B+hora_ingreso, B+gps_ingreso {lat,lng,accuracy,timestamp, gps_dudoso?, alerta_lejos?,
         culpa_tecnico?, distancia_cita_metros?} + historial ; tope offline de 8 s
  si no es apoyo y monto > 0: recordar foto del comprobante (una vez por cita)

Reglas de GPS: | Regla | Valor | |---|---| | Reutilizar la última posición del mapa | Si tiene menos de 30 s; se descarta al volver de segundo plano | | Primer intento | Alta precisión, timeout 15 s, sin caché | | Segundo intento | Baja precisión, timeout 10 s, caché de 30 s | | GPS dudoso | accuracy > 150 m, se anota sin preguntar | | Fuera de rango | (distancia - accuracy) > 300 m | | Sin GPS | Se guarda igual con aviso |

Lección: con 2 s de timeout el GPS se rendía dentro de las casas y el ranking de puntualidad culpaba al técnico.

Borrar el ingreso: deleteField() de hora y GPS, con _gps_backup en el historial.

8.6 Salida de la cita

Orden de validación:

  1. Ficticia: no se puede.
  2. Sin ingreso no hay salida (salvo gerencia).
  3. Reporte completo (salvo apoyo):
faltantesReporteParaSalida(rep):
  rep.condicion
  al menos 1 foto (rep.fotos + secciones.*.fotos)
  algún comentario (en secciones, comentario_cliente u observaciones)
  si reportado_en >= 9-oct-2026 11:30: condición de los grupos roedores, insectos y especiales
  1. Método de pago con respaldo (asegurarMetodoDePago, 8.8). Si el técnico cierra la hoja, no hay salida.
  2. Mismo GPS, misma alerta y mismo batch que el ingreso (hora_salida, gps_salida).
  3. Si la cita no tiene condición, a los 400 ms sale un modal chico "¿Cómo quedó?" que graba solo la condición.
  4. Si todas las citas del día tienen salida, aviso de revisión semanal de EPP.

Corrección del punto por el técnico (punto de la cita mal cargado): lectura de alta precisión, distancia al original (verde < 100 m, amarillo < 500 m, rojo más), precisión (excelente hasta 10 m, buena 30, moderada 100, baja más), frescura (en vivo hasta 5 s, reciente hasta 30 s), foto del frente obligatoria (800 px, JPEG 0,6, Storage ubicaciones/{citaId}_{ts}.jpg). Escribe metadata.coordenadas_corregidas {lat, lng, distancia_metros, precision_metros, edad_senal_segundos, foto_url, foto_path, corregido_por, corregido_en, estado_revision}; oficina la aprueba o rechaza. El punto corregido cuenta para la distancia de ingreso y salida.

Corrección manual de ingreso y salida (solo gerencia): mapa con dos marcadores arrastrables (E y S), hora sugerida igual a inicio e inicio + duración; escribe gps_* {..., accuracy:0, manual_fix:true} y en el historial "(fix manual)". En la agenda (agenda-gps-fix.js) además permite eliminar un registro.

8.7 Reporte de servicio

Estructura de metadata.reporte:

condicion: 'rojo' | 'amarillo' | 'verde'          // grave, leve, libre de plagas
grupos: { roedores, insectos, especiales }        // desde 9-oct-2026
plagas: [{ nombre, condicion }]
secciones: {
  servicio:   { comentario, fotos[] }
  roedores:   { fotos[{url,path}], audios[{url,path,segundos}] }
  insectos:   { ... }
  especiales: { ... }
  (viejas: limpieza, diagnostico, resultados; alias ratas → roedores)
}
hoja_servicio: { url, path }                      // foto de la hoja física firmada
comentario_interno                                // nunca sale al cliente
reportado_por, reportado_en, editado_por, editado_en, reporte_escrito_por?
Grupo Plagas
Roedores Roedores
Insectos Cucaracha alemana, cucaracha americana, hormigas, pulgas, chinches, zancudos, moscas, otro insecto
Termitas y animales Comején, murciélagos, mapaches, zorros, otro

Árbol de condiciones:

condGrupo(k) = si hay plagas marcadas del grupo → peor(condición de cada una) ; si no → grupos[k]
general = peor(pendienteGeneral, condGrupo de cada grupo)
"Libre" en un nivel → todo lo que cuelga pasa a verde
"Leve" o "Grave" en un nivel → queda pendiente hasta que algún hijo tenga ese color ("?" en los hijos)
no se puede bajar un nivel por debajo de su peor hijo
tocar la condición de una plaga no marcada la marca

Validación al guardar: cada plaga marcada con condición, sin pendientes, cada grupo con condición, un grupo que no está en verde necesita al menos 1 foto subida, general resuelta. Reportes anteriores al 9-oct-2026 11:30 no exigen esto.

El reporte no registra productos ni dosis por cita; el consumo se liquida por bodega al final del día. El nivel de infestación es el semáforo.

Fotos y audios:

  • Evidencia: 800 px, JPEG 0,6, hasta 100 por reporte. Hoja de servicio: 1600 px, calidad 0,8 (para leer la firma).
  • Storage reportes/{citaId}_{seccion}_{ts}_{4 caracteres}.{jpg|webm|m4a}; reintento de subida máximo 60 s.
  • Cada foto se guarda sola al terminar de subir: updateDoc({'metadata.reporte.secciones.<k>.fotos': arrayUnion({url,path})}) contra el citaId anotado al iniciar la subida. Lección del 2-sep-2026: 269 fotos perdidas porque un autoguardado tardío de una cita cayó en otra y el guardado reemplazaba el reporte entero.
  • Quitar una foto: arrayRemove con el valor exacto guardado.
  • Fotos fallidas en rojo con Reintentar; cerrar con subidas en curso pide confirmación.
  • Audios con MediaRecorder, tope 3 minutos, se descartan los de menos de 1.000 bytes.
  • Comentario general dictado por voz (reconocimiento del navegador en español de Costa Rica) y pulido por IA (pulirComentarioReporte); el técnico elige original o pulido.

Autoguardado y guardado:

autoGuardar (debounce 500 ms en chips, 1.500 ms en texto):
  cita = la del modal; si la abierta cambió → descartar
  si hay guardado en vuelo → marcar pendiente y repetir
  updateDoc por rutas de punto (condición, plagas, grupos, comentario, editado_*,
    reportado_* solo si no existían, arrayUnion de fotos que no se guardaron solas) ; tope 8 s
Guardar:
  esperar hasta 30 s a que terminen las subidas
  validar árbol y evidencia; confirmar si algo no subió ("lo que no subió no existe")
  • El técnico del reporte es el del ruteador de esa ruta y día; si escribe otro, queda en reporte_escrito_por.
  • Oficina edita con js/reporte-editar.js: lee fresco, comprime a 1600 px calidad 0,82 y escribe metadata.reporte completo (excepción mitigada con la relectura), historial "(editado desde la agenda)". ubicaciones_mapa.html tiene otro editor que conserva la hoja firmada y repone fotos que no cargó.

Firma del cliente: no hay firma digital en pantalla. Respaldo en foto: la hoja de servicio firmada (opcional; si falta se marca "sin hoja firmada") y el recibo de efectivo del talonario.

8.8 Cobro

Contrato compartido (js/pagos-cita.js, para que ruta y agenda escriban lo mismo):

metadata.pagos               [{ metodo, monto, linea?, motivo? }]
metadata.metodoPago          método de la parte más grande
metadata.precio_final_rutero suma de las partes (null si 0)
metadata.motivoPago
metadata.voucherPago         { url, por, en }   Storage vauchers/{citaId}_{ts}.jpg
metadata.reciboEfectivo      { url, por, en }   Storage recibos_efectivo/{citaId}_{ts}.jpg
  • Si no existe pagos, el arqueo viejo (método más precio final, o monto) se lee como una línea.
  • Métodos: efectivo, SINPE (transferencia móvil), datáfono (tarjeta), link de pago, transferencia bancaria, cuenta por cobrar, cheque, no aplica. El mixto viejo se lee pero ya no se ofrece: el pago se parte.
  • Motivo obligatorio: cuenta por cobrar (atajos "crédito", "no estaba presente", "es empresa") y no aplica ("no se hizo").
  • Pago partido: mínimo 2 partes positivas; lo que falta cae en la primera parte no tocada; si suman más que la cita se avisa y se acepta. Lo cobrado puede diferir de lo vendido.
  • Pago por línea de servicio (linea: i); no puede convivir con el arqueo de la cita entera (contaba la plata dos veces).
  • Respaldos: datáfono sin voucher o efectivo sin recibo bloquean. Fotos a 1000 px calidad 0,7 en campos separados (una cita mixta necesita las dos).
  • metadata.monto es la venta (solo gerencia o la función); precio_final_rutero es lo cobrado, editable por cualquier empleado mientras inicio + 18 h > ahora.
  • Tocar confirmar sin cambiar el monto no escribe (llenaba el historial).
asegurarMetodoDePago(cita):
  si hay método y no falta respaldo: ok
  si monto <= 0: ok
  abrir hoja de métodos → motivo → voucher → recibo
  si el técnico cierra: false (no se marca la salida)

8.9 Liquidación y cierre del día

js/ruta-inventarios.js con tres paneles (vehículo, mi bodega, mi especial). Lee stock y movimientos con caché de 2 min; el neteo de especiales cubre 8 días contables más 1 (despacho fecha el alisto al mediodía del día de ruta y lo hace la tarde anterior).

listarEspecialesDeRuta():
  por bodega especial de cada técnico de la ruta:
     netearPorDia(movimientos)          // entradas - salidas por día contable; reversión de gasto resta salida
     toparConStock(saldos)              // "el juez es el stock": se recorta al stock real, del día más reciente al más viejo
  liquidable = min(saldo, disponible) > 0
  días futuros (alisto de mañana cargado hoy) no se liquidan
  • Liquidar un especial obliga a elegir devolución (a la principal) o gasto con número de recibo entero mayor a 0, sin preselección ("contablemente son dos cosas muy distintas"). Personal: gasto sin recibo. El gasto va a la cuenta de gasto natural con fecha de cierre del día contable (23:00).

Bloqueo de apagar (faltasDeLiquidacion): citas sin arquear, datáfono sin voucher, efectivo sin recibo ("hacé el depósito hoy mismo"), unidades en la especial, inventario personal sin revisar renglón por renglón. Las bodegas de compañeros no traban. Devuelve null mientras no se sepa.

Reporte de cierre: PNG dibujado en canvas con cobros por método, especiales, inventario y fotos de vouchers y recibos, para copiar o compartir.

8.10 Offline

guardarConTopeOffline: carrera entre la escritura y 8 s. Si gana el reloj, la UI se actualiza y avisa "guardado en el teléfono, se envía solo cuando haya señal, no lo registres de nuevo". Usan el tope: ingreso, salida, método de pago, condición, autoguardado, fotos ya subidas, quitar evidencia. Esperan red: pago partido, precio final, voucher, recibo, corrección de GPS, ficticia y alisto. Storage no tiene cola offline: falla al minuto y se reintenta desde la miniatura.

8.11 Lecciones con fecha

Fecha Lección
08-ago-2026 Se quitó una consulta duplicada de citas por rango de hora: doble lectura en cada carga
27-ago-2026 Un monto con céntimos se multiplicó por 100 al releerse
02-sep-2026 269 fotos perdidas por autoguardado en otra cita y reemplazo del reporte entero
09-oct-2026 11:30 Reporte por grupos con evidencia obligatoria
19-oct-2026 Turno solo en la central (300 m más precisión hasta 150 m)
21-oct-2026 Bodegas de vehículo apagadas
22-oct-2026 Cita de apoyo recortada a la franja, ingreso y salida propios
24 y 26-oct-2026 Objeto de cita viejo: reporte en uno, salida en otro
28-oct-2026 La ficticia no lleva reporte
29-oct-2026 Tickets de citas en rojo y revisión IA de la nota

9. Después del servicio: reportes, citas en rojo, certificados, garantías y portal

Todo lo que pasa después parte de metadata.reporte con metadata.hora_salida. "Reporte cerrado" significa que la cita tiene salida: antes es un borrador que se autoguarda y los procesos de servidor no lo miran.

9.1 Tres caminos de reporte al cliente

Camino Para quién Acceso
Reporte de cita en rojo (reporte_cita.html?t=<token>) Cliente de una visita con condición grave Token aleatorio en el enlace, sin login
Reporte consolidado mensual (reporte_cliente_citas.html) Cliente empresarial con varias visitas Teléfono ofuscado en el enlace más clave, validada en servidor
Portal del cliente Clientes de cartera con cuenta Proyecto aparte, espejo de datos (9.7)

9.2 Citas en rojo: ticket al vendedor

Objetivo: cuando el técnico cierra una visita en rojo (plaga grave), el vendedor debe ofrecer un repaso con precio especial y mandarle al cliente su reporte.

Corre dentro del trigger garantiaRefuerzoOnCita (functions/tickets-rojo.js):

esRojoCerrado(c) = reporte.condicion == 'rojo' && hora_salida && ruta != 'Ruta 20'
                   && !ficticia && !demo && fecha_dia >= fecha de arranque del módulo
alGuardarCita(antes, despues):
  si ni ahora ni antes es rojo cerrado: salir
  si dejó de serlo y hay ticket abierto: cerrar con cierre='ya_no_es_rojo'
  si ya existe ticket y cambió el reporte: rehacer reportes_publicos/{token} con merge
  si no existe:
    pct  = config_tickets_rojo/general.pct (1..100) o 70
    base = precio_final_rutero || monto
    token = 12 bytes aleatorios en base64url
    crear reportes_publicos/{token} con comentario = ''      // vacío hasta pasar la revisión IA
    crear tickets_rojo/{citaId} = {token, estado:'abierto', fecha_dia, ruta, cliente, telefono,
        vendedor: vendedores[0], vendedores, plagas_rojo, monto_base: base, pct,
        precio_repaso: redondear(base * pct / 100 a centenas), enlace, creado_en}
    "already exists" se ignora (carrera entre dos disparos)

Ejemplo: pagó ₡40.000 con 70 % → repaso ₡28.000.

reportes_publicos/{token}: citaId, cliente, fecha_dia, condicion, grupos, plagas[] (solo no verdes), fotos[] (hasta 24 {url, seccion}), comentario_tecnico (nota cruda, hasta 2.000 caracteres, concatenada de las secciones, nunca el comentario interno), comentario (lo que ve el cliente), revision{}, dias_repaso: 14. Reglas: lectura pública por id, listado solo empleados, escritura solo servidor.

Página pública: nivel en rojo, "Qué encontramos", "Lo que anotó el técnico" (solo el texto revisado), fotos, "Nuestra recomendación" (repaso a 14 días con fecha calculada) y botón de WhatsApp prellenado. No muestra precio, vendedor, técnico ni ruta. Todo escapado.

Reglas de tickets_rojo: lectura empleados; crear y borrar solo servidor; un empleado solo actualiza precio_repaso, precio_editado_por, precio_editado_en, intentos. config_tickets_rojo solo gerencia.

9.3 Revisión IA de la nota del técnico

Trigger revisarReporteRojo sobre reportes_publicos/{token} (functions/revision-reporte-rojo.js). Evita que al cliente le lleguen: groserías, juicios sobre la persona o su casa, lenguaje discriminatorio, datos de terceros, promesas que la ley de protección al consumidor obliga a cumplir ("garantía", "erradicar", "100 %", "no vuelven", "libre de plagas"), afirmaciones médicas o comentarios internos.

alEscribirReporte(token, doc):
  si no hay comentario_tecnico: salir
  h = sha1(comentario_tecnico)
  si doc.revision.hash == h:                      // anti bucle: el propio update re-dispara el trigger
     si doc.comentario != revision.texto: reponer comentario
     salir
  r = revisar(texto)
  update reportes_publicos: comentario = r.texto, revision = {hash, estado, texto, cambios, modelo, en}
  copiar revision a tickets_rojo/{citaId}
revisar(texto):
  vacío → ok
  llamar al modelo de lenguaje con salida JSON {hay_problema, texto, cambios[{antes, despues, motivo}]}
     instrucciones: si no hay nada, devolver el texto letra por letra igual; cambiar solo la frase;
     no agregar; no bajar la gravedad; no alargar
  error de red o parseo → estado 'error', texto ''     // no se publica la nota
  si !hay_problema:
     si politicaPromesas.revisar(original) ok → 'ok' con el ORIGINAL (si la IA "mejoró", se ignora)
     si no → 'error'                                   // la regla dura gana
  si largo(nuevo) > largo(original) + 10 → 'error'
  si politicaPromesas.revisar(nuevo) falla → 'error'
  → 'corregido' con hasta 10 cambios
  • Con error el cliente ve nivel, plagas y fotos, pero no la nota.
  • politica-promesas.js existe en dos copias idénticas: navegador (avisa) y servidor (decide). Detecta "garantía" y "erradicar" en subcadenas, partidas con espacios y con 1 o 2 letras de distancia de edición (palabras de 6 a 16 letras; 2 de distancia si tiene 8 o más). Ignora enlaces propios.

9.4 Verificador de tickets y burbuja roja

ticketsRojoVerificar (cada 10 minutos, zona Costa Rica, 512 MiB, 300 s), para cada ticket abierto:

  1. Rehace la copia del cliente si el ticket no tiene revisión o si el hash no coincide con la nota actual (cubre tickets anteriores a la revisión y un bug donde la nota se leía de la sección equivocada).
  2. Busca envío posterior a creado_en: en la línea oficial (mensaje saliente no fallido con la plantilla del repaso, o con el token, o con el enlace de la encuesta de esa cita) y en los teléfonos de empresa (chats del cliente con mensajes propios que contengan el enlace). De paso calcula por qué canal va la conversación.
  3. Si no hubo envío, busca un refuerzo agendado: cita del mismo teléfono desde el día siguiente, no ficticia ni cancelada, creada después de la visita y del mismo cliente (nombre normalizado o misma sede: las cuentas corporativas tienen decenas de sedes con el mismo teléfono). Si la hay: estado:'enviado', cierre:'refuerzo_agendado'.
  4. Con envío: estado:'enviado', envio{canal, tipo, por, en}, cierre:'mensaje_enviado'.
  5. Si nada: actualiza conversacion{canal, numero, ultimo_ms} solo si cambió.

Burbuja roja en la agenda (js/agenda-fab-rojo.js): listener de tickets abiertos (del vendedor, o todos para gerencia) y enviados de los últimos 3 días. Tarjeta con cliente, plagas graves, "Pagó ₡X → Repaso ₡Y" editable y fecha sugerida. Mensaje armado con primer nombre, fecha de visita, plagas, enlace, repaso a 14 días y precio. Canales: plantilla por el CRM (abre el embudo del vendedor con parámetros), WhatsApp de empresa o copiar. Botones apagados hasta que exista la revisión. Cada clic anota intentos[]. Gerencia edita el porcentaje.

9.5 Reporte consolidado mensual con clave

  • reportes_cliente/{telefono}: clave, mes, nombre_cliente, comentario_general, citas[] (copia de las citas elegidas con condición, plagas, secciones, fotos, servicios, hoja), citas_mapa[], generado_por, generado_en.
  • Lo genera un empleado desde ubicaciones_mapa.html: marca citas del mes, opcionalmente pide a la IA un comentario consolidado (generarComentarioConsolidado: 3 a 4 oraciones con hallazgos que requieren acción del cliente; si todo está verde, frase fija) y genera.
  • Clave: palabra de una lista de 30 más un número de 1000 a 9999, o manual (mínimo 4 caracteres, sin espacios). Regenerar conserva la anterior.
  • Enlace con el teléfono ofuscado (XOR con una cadena fija y base64url; es ofuscación, no seguridad).
  • Endpoint reporteCliente (POST sin login): teléfono de 6 dígitos o más; límite de 12 intentos por teléfono por minuto con transacción en _rate_limits/rep_<tel>_<minuto> (expira a 24 h, responde 429); 404 si no existe, 401 si la clave no coincide; devuelve el documento sin la clave. La colección solo la leen empleados.
  • La página muestra índice por condición, mapa con puntos por color, cada cita con observaciones, fotos, audios, hoja firmada, servicios con total y texto por condición, aviso de confidencialidad y botón de PDF.

9.6 Hoja de servicio

hoja_servicio.html es una plantilla imprimible estática (Carta vertical) que replica la hoja de papel que firma el cliente. No lee ni escribe base de datos (permiso cumplimiento_salud). La versión firmada vuelve como foto en metadata.reporte.hoja_servicio.

Contenido, basado en los datos obligatorios del reglamento sanitario de control de plagas (razón social, dirección, teléfono, nombre comercial y genérico de cada producto, medidas de seguridad antes, durante y después):

  • Frente: valoración global en semáforo (Libre, Moderada, Grave) y "requiere repaso"; datos de la visita (cliente, dirección, fecha, horas de inicio, fin y reingreso, dos técnicos); precauciones (mascotas en otra habitación hasta que seque; asmáticos y adultos mayores reingresan a los 120 min; niños a los 60 min); diagnóstico por plaga; productos aplicados (lista de unos 13 con nombre comercial y genérico, casillas sí y no, dos líneas "Otro"); medidas de seguridad; condición de limpieza (óptima, adecuada, requiere mejoras, favorece la reinfestación); próximo servicio (2 semanas si es grave, 1, 3 o 6 meses); refuerzo con fecha y precio; monto y método de pago; emergencias; firmas.
  • Reverso (términos): objetos de valor, distancia de 5 m, ventilar 2 h; el servicio es de control, no de erradicación; verde con garantía de 30 días naturales y reclamo con evidencia dentro de 5 días de la reaparición; amarillo con nuevo servicio en 30 días o menos; rojo requiere servicios adicionales; locales de alimentos con control mensual; aviso previo a emergencias en termonebulización; cebos solo en cebadera con llave.

9.7 Certificados de fumigación y verificación por QR

Regla central: el certificado se registra en la base antes de dibujar el QR, y el QR solo lleva el serial. Lo que no está en la base no es un certificado.

certificados_emitidos/{serial}: | Campo | Notas | |---|---| | tipo | fumigacion o capacitacion | | establecimiento | Fumigación; puede llevar varias líneas separadas por ; | | direccion | "provincia, cantón, distrito" | | servicio | Nombres de los servicios de la cita; por defecto "Fumigación" | | fecha | AAAA-MM-DD, día operacional de la cita | | vigenciaDias | Por defecto 30 (de metadata.validez_dias), 1 a 365 en pantalla | | citaId, emitidoPor, emitidoEn | | | nombre, cedula, curso, porcentaje | Capacitación |

Reglas de Firestore: get público; list empleados; update y delete prohibidos; create solo empleado con lista cerrada de claves, serial que calce con CERT-EP-[0-9]{4}-[A-Z0-9-]{6,40}, campos obligatorios por tipo, topes de largo, vigencia de 0 a 3.650, porcentaje de 0 a 100 y emitidoPor igual al usuario que escribe.

Numeración: CERT-EP-<año>-<8 caracteres> con alfabeto base32 sin I, O, 0 ni 1 (dictable por teléfono), generado con crypto.getRandomValues. No es correlativo; un choque haría fallar el create.

Emisión:

  1. Desde la agenda: cada renglón del campo certificados es un establecimiento; provincia, cantón y distrito de la cita; fecha operacional.
  2. Se abre la pestaña nueva antes del await (para que el navegador no la bloquee); si falla el registro se cierra y no se genera nada.
  3. Un certificado abre certificado_fumigacion.html?s=<serial>; varios van a certificados_seccion.html (un certificado por hoja).
  4. Desde despacho: mismo mecanismo para varias rutas.

Páginas: certificado_fumigacion.html?s= lee la base sin sesión y pinta la hoja (Carta horizontal) con QR que apunta a sí misma y "verificado en la base"; sin ?s= es un enlace viejo con datos en la URL y se marca "sin verificación". "Válido hasta" = fecha + vigencia. verificar-certificado.html verifica ambos tipos (capacitación con puntaje mínimo 90). Generador de PDF con los certificados anuales de capacitación de cada técnico (4 cursos, el aprobado más reciente fija la vigencia de 12 meses).

9.8 Garantías del lado de la cita

garantias/{token} (token de 28 caracteres base36 aleatorios):

  • Al crear desde la cita (botón Garantía): datos de la cita origen, estado:'solicitud_enviada', creado_por; la cita recibe garantiaId. Si ya hay caso, el botón abre su seguimiento (antes se creaban dos o tres enlaces por cliente).
  • El cliente llena garantia.html?t=<token> sin login: motivo (hasta 2.000 caracteres) y evidencia obligatoria desde el 30-oct-2026 (hasta 20 archivos; fotos a 1600 px calidad 0,82; videos hasta 60 MB) en Storage garantias_publicas/<token>/.
  • Estados: solicitud_enviada → pendiente_revision → aprobada o rechazada → realizada (visita agendada); cierres cliente_desiste, cliente_no_contesta.
  • Reglas: lectura pública por id, listado empleados, borrar nunca; el cliente sin login actualiza una sola vez (de enviada a pendiente, solo sus 6 campos); cualquier empleado puede marcar realizada con la cita agendada o agregar seguimientos; el resto lo hace quien gestiona garantías.

Automatismos:

vincular(cita):                         // dentro de garantiaRefuerzoOnCita
  si cancelada, Ruta 20, ya ligada o ficticia: salir
  abiertas = garantías del teléfono, no cerradas, no demo
  calza si no es la cita origen, no fue descartada,
        inicio >= día del reclamo (holgura 6 h) y <= creado_en + 60 días
  tomar la más reciente
  si monto <= 0: transacción → garantía 'realizada' con vinculo:'auto'; cita es_garantia=true, garantia_de
                 (aunque no estuviera aprobada: decisión de gerencia)
  si monto > 0: refuerzos_posibles += {citaId, fecha_dia, ruta, monto}   // alguien confirma o descarta
  • Cancelar la visita con el motivo "no aceptó" o "no contesta" cierra el caso.
  • garantiaAvisoRespuesta (al pasar a pendiente de revisión): avisa por WhatsApp al grupo configurado con cliente, días desde el servicio (marcado si pasan de 30), condición del reporte, cantidad de evidencias, motivo, enlace al caso y mención al vendedor.
  • garantiasResumenDiario (lunes a sábado 8:00): casos por decidir con 1 día o más (8 más viejos), aprobados sin visita (5), formularios sin llenar con 2 días o más, agrupados por quien generó el enlace. Candado diario.
  • Panel garantias.html: busca espacios libres de 30 min en los próximos 7 días pegados a una cita existente de la misma ruta y turno, ordenados por distancia; "Agendar aquí" aprueba y abre la agenda con la cita en ₡0. Los mensajes al cliente nunca dicen "garantía" (política de promesas).

9.9 Portal del cliente al cerrar el reporte

portalPushAlCerrarReporte (onWrite de citas, 1 GiB, concurrencia 4): si el reporte cambió y el teléfono pertenece a una cuenta de cartera (portal_cuentas con telefonos array-contains o por id), relee 24 meses de citas de la cuenta, infiere sedes (por nombre, o por GPS a 120 m; antes 75 m partía la misma sede en dos) y escribe portal_cuentas/{cuenta}/reportes/{citaId} recortado (sin comentario interno ni monto) y visitas/{citaId} futuras (sin técnico, ruta ni monto). Triggers espejoPortal* replican esos documentos a otro proyecto (el portal), en una sola vía, con una cuenta de servicio en secretos.


10. Encuestas y satisfacción del cliente

10.1 Encuesta por cita

Colecciones: encuestas_citas/{citaId} (principal); encuestas y encuestas_id (flujo viejo con id base36 de 9 caracteres); justificaciones_sin_encuesta/{citaId}.

Generación (agenda):

generarLinkEncuesta(cita, tipo):
  si es ficticia: bloquear ("la encuesta va en la cita de la visita")
  si venció plazoEncuesta (5.9): bloquear; la justificación queda en solo lectura
  si encuestas_citas/{citaId}.estado == 'respondida': devolver la URL sin escribir
  setDoc merge {cita_id, tipo_encuesta, estado:'pendiente', fecha_link_generado}
  URL = encuesta.html?c={citaId}&t={tipo}; copiar invitación al portapapeles

Tipos y número de preguntas: hogares 17, locales 15, garantías 6, especiales 12, roedores 14, áreas comunes 21.

Página pública encuesta.html (sin login): una pregunta por pantalla, comentario opcional, avance automático. Tipos: escala 1 a 5, sí y no (con N/A), puntualidad (sí, no, "no, pero me avisaron"), texto.

principal = satisfacción con el técnico
resto = preguntas puntuables sin la principal ni la del vendedor
scorePrincipal = (v - 1) / 4 * 100
resto: N/A = 100; escala = (v - 1)/4*100; puntualidad sí o avisado = 100; sí/no: sí = 100, no = 0
final = 0,5 * scorePrincipal + 0,5 * promedio(resto)
etiqueta: >= 95 Bueno ; >= 90 Aceptable ; si no "Requiere atención"
scores = {satisfaccion_tecnico, satisfaccion_vendedor (aparte), cumplimiento, final, etiqueta}

Reglas de la respuesta pública: estado respondida, solo campos de una lista cerrada, una sola vez, respuestas máximo 60 elementos, user_agent 400 caracteres. Antes era update: if true y cualquiera podía reescribir encuestas ajenas.

Reporte (reporte_encuestas.html, permiso reportes_gerenciales): une todas las fuentes; técnicos de cada encuesta por cita → ruteador/{fecha_dia}_{ruta} (lección: derivar el id con UTC acreditaba las citas de la tarde a la cuadrilla del día siguiente); vistas individual, ranking por técnico, ficha y diagnóstico con "impacto negativo" (promedio sin esa encuesta menos promedio con ella); muestra novedad_tecnico (lo que el técnico reportó antes de que el cliente se quejara).

10.2 Satisfacción por WhatsApp (clasificación con IA)

satisfaccionClientes corre a las 4:30 todos los días (zona Costa Rica, 1 GiB, 540 s).

unidad: cita de Ruta 1..19, no garantía, no ficticia, desde la fecha en que los teléfonos de empresa quedaron completos
ventana de cada cita: inicio + 1 h hasta inicio + 14 días, o hasta la siguiente cita del mismo teléfono
madura: fecha_dia <= hoy - 15 (después no se reclasifica salvo cambio de versión del método)
rango: desde hoy - 15, más rezagadas de 60 días sin madurar o pendientes
texto por cita: líneas "MM-DD HH:MM CLI|EP: texto" (empresa cortado a 300, cliente a 1.500);
                la encuesta respondida entra como "[ENCUESTA n de 100, comentarios]"
audios del cliente: transcritos con un servicio externo de voz a texto, caché por sha1(url)
si hash(texto) igual y misma versión: reusar clasificación
modelo de lenguaje con salida JSON, prompt en caché, lotes de 6, hasta 3 intentos (5 s × intento ante 429 o 5xx)
categoría única: contento, tramite, duda, reclamo_leve, reclamo_fuerte, silencio (chat sin respuesta), sin_chat
                 (las dos últimas sin IA); pendiente si la IA falla
causa (prioridad): plaga (igual, bajo, volvio) > ejecución (incompleto, metodo, danos, trato)
                   > agenda (tarde, cambios) > cobro (distinto, precio) > atención (seguimiento, info) > otro
si hay elogio y reclamo: gana el reclamo
escribe satisfaccion_citas/{citaId} (lotes de 400) y satisfaccion_semanas/{lunes} con porcentajes,
  causas, plagas, desglose por vendedor y técnico, madura

La pantalla satisfaccion_clientes.html muestra la última semana definitiva y la semana en curso marcada "todavía sube"; en las matrices por persona una celda se colorea solo con 5 citas o más.


11. Automatizaciones de servidor de la operación

11.1 Optimus: espacios de agenda para el bot de ventas (functions/optimus.js)

Propósito: que el vendedor virtual de WhatsApp proponga al cliente horarios reales pegados a otras citas cercanas, y cree la cita cuando el cliente elige. Es la versión de servidor del buscador de la agenda, con una corrección de fondo: cada inicio propuesto se valida contra todas las citas de ese día y esa ruta, no solo contra la vecina.

Reglas fijas: | Regla | Valor | |---|---| | Ventana de trabajo | Inicio entre 07:00 y antes de 20:00 hora local | | Rutas por defecto | Ruta 1 a Ruta 10 | | Radio | 5 km | | Traslado entre citas | 30 min | | Rango | Desde mañana, 14 días | | Resultados | 3 | | Misma ubicación | Una cita a menos de 10 m es el mismo inmueble, no una vecina | | Ocupación extra | Propuestas del bot en estado elegida o aprobada (bot_ventas_propuestas) cuentan como ocupadas |

proponerEspacios(lat, lng, duracion, opciones):
  citas = citas del rango (sin Ruta 20, sin asignar ni canceladas) + propuestas elegidas
  porDiaRuta = índice fecha|ruta
  para cada cita c con coordenadas, en ruta permitida, día no evitado, 10 m < dist <= radio:
     para posición en [antes: c.inicio - (duracion + traslado), después: c.fin + traslado]:
        descartar si ya pasó, si está fuera de 07:00-20:00 o cae en otro día operacional
        descartar si choca con alguna cita de esa ruta y día (solape puro)
        descartar si choca con alguna otra (sin contar la vecina) dejando el traslado como colchón
  leer ruteador de cada día y ruta sobreviviente; descartar si toca un bloqueo
  score = 100
        - min(40, 6 * días desde hoy)
        - min(40, 8 * km)
        + 10 si la ruta ya tiene técnicos
        + 10 si la zona del ruteador coincide con la del cliente
        - 15 si es "antes" y empieza antes de las 8
        + 8 si es un día de la semana preferido
        + 12 si está a 60 min o menos de la hora preferida
  deduplicar por fecha_ruta_hora quedándose con el mejor
  elegir primero un espacio por día distinto (opciones reales al cliente), completar con los mejores
  devolver ordenados por hora, con motivo "A X km de otro servicio en <distrito> el <día> a las <hora>"
validarEspacio(...): justo antes de crear, relee el día y el ruteador; ignora la propuesta que se aprueba
crearCita(...): teléfono 506 + 8 dígitos obligatorio, coordenadas obligatorias, valida espacio,
  arma el documento con la misma forma que una cita hecha a mano (estado 'agendada', confirmada false,
  servicios, detalle_cotizacion, validez 30, modalidad 'bot_optimus' o la indicada) y crea o actualiza clientes/{tel}
  sin pisar un nombre existente con uno vacío

La ubicación del cliente (pin, enlace de mapas, texto como "barrio X de cantón Y") se resuelve con functions/geo.js (11.8).

11.2 Compuerta "agendar sin optimizador"

Se mide cuántas citas de cada vendedor entran por el optimizador (modalidad_cita_creada = optimizador_algoritmo). Cuando el vendedor está por debajo del mínimo de uso, la agenda exige una justificación escrita; la función revisarJustificacionSinOptimus (POST, empleado) la revisa con un modelo de lenguaje: rechaza textos de menos de 100 o más de 4.000 caracteres, sanitiza la entrada y devuelve un veredicto valida, debil o invalida (restricción real de la cita frente a relleno para pasar el contador). Desde el 29-oct-2026 el arrastre en el cronograma lleva directamente a Compás, que cuenta como optimizador.

11.3 Envío de citas al grupo Ventas (functions/cita-a-ventas.js)

Propósito: que todo el equipo de ventas vea cada venta nueva con un mapa de la ruta de ese día, para detectar citas agendadas lejos del resto.

Función Tipo Qué hace
enviarCitaAVentas HTTP POST (empleado) Botón "Enviar a Ventas" de la Vista Ventas. Body {citaId, imagen, forzar}. Si ya se envió responde 409 ya_enviada y el navegador pide confirmar con forzar
ventasAutoEnviar onCreate de citas Espera 3 min y manda sola la cita si califica
ventasPantallazo HTTP POST (empleado) accion: preview (vista previa de una cita nueva o movida) o movida (manda la cita movida al grupo etiquetando al vendedor)

Envío automático:

automatico(citaId):
  cfg = wa_equipo_config/ventas_auto ; si cfg.activo == false: salir
  esperar cfg.minutos (3 por defecto)        // el 97 % de los envíos manuales ocurría antes; las correcciones
                                              // de hora o precio pasan en los primeros 2 minutos
  releer la cita; enviar solo si existe, tiene fecha_dia, monto > 0, vendedor, no es Ruta 20,
       no es ficticia, no está cancelada y nadie la mandó ya (ventas_envios/{citaId})
  número de salida = teléfono de empresa conectado del primer vendedor (el de menor orden) o el bot de avisos
  mandar(...)

Una ejecución por cita, para que un lote salga como pantallazos individuales.

La imagen (1620 × 1080, enviada a escala 2) se arma en el servidor con HTML y un navegador sin cabeza:

  • Cabecera amarilla para rutas diurnas y negra para especiales: cliente, día y hora, lugar (acrónimo de provincia, cantón, distrito), "cliente nuevo" o número de vez (sin contar ficticias), servicios, observaciones y monto.
  • Columna izquierda: línea de tiempo de la ruta con eje fijo por turno (diurnas 7 a. m. a 4 p. m., nocturnas 3 p. m. a 1 a. m., se abre si una cita se sale), bloques por duración, en cada hueco el manejo estimado y en rojo si no cabe o si se pisan.
  • Mapa: recorrido por calles entre las citas de la ruta en orden de hora, tramos de esta cita en rojo con su tiempo, las otras rutas del mismo turno de fondo. Teselas de un proveedor de mapas con llave (cupo gratuito mensual amplio); sin llave cae a teselas libres.
  • Cita movida: banda roja "CITA MOVIDA" con antes y ahora, y dos columnas de ruta (de dónde venía, dónde quedó); el texto etiqueta a los vendedores y dice quién la movió. Rastro en ventas_movidas.
tiemposDeManejo(citas):                    // servicio público de ruteo OSRM, sin llave ni costo
  una consulta con todos los puntos en orden de hora; timeout 8 s (si no contesta, la imagen sale sin tiempos)
  por tramo: min = max(1, redondear(duración/60)), metros, trazado aligerado a ~80 puntos
resumir(d): "Parada N de M. Desde la anterior: X min de manejo, Y libre (o 'se pisan Z', 'no alcanza'). ..."

Salida: imagen a Storage, documento en wa_equipo_salida {numero, chatId, tipo:'imagen', ruta, texto, hd, estado:'pendiente', creadoPor} con texto "Venta de ", teléfono y enlaces cortos a la cita en la agenda y al mapa de rutas del turno; rastro ventas_envios/{citaId} {por, numero, cuando, salidas[], veces}. La agenda pide la vista previa al guardar una cita nueva y al soltar una cita arrastrada.

11.4 Línea de coordinación por cita (functions/wa-coordinacion-citas.js)

Guarda en cada cita por cuál WhatsApp de la empresa se conversa con ese cliente, para mandar luego la encuesta por ahí. Solo con evidencia.

waCoordinacionCitasHoraria: minuto 20 de cada hora, zona Costa Rica, citas de hoy a +7 días. waCoordinacionCitasNow (HTTP, gerencia) acepta dias (hasta 120) y desdeDias (hasta −120) para rellenar historia.

evidencia:
  teléfonos de empresa: wa_equipo_cuentas/{numero}/chats (sin grupos ni cuentas privadas), con asignado y ts
  línea oficial: whatsapp_conversations/{tel} con lastMessageAt y assignedTo (lecturas de a 100)
decidir(cita):
  1. vendedor_cita: un teléfono de empresa asignado a un vendedor de la cita con chat del cliente
  2. crm: la línea oficial tiene conversación
  3. otro_equipo: cualquier otro teléfono de empresa con chat
  en el mismo escalón gana el chat más reciente; sin candidatos no se escribe nada
escribe citas/{id}.whatsapp_coordinacion = {linea, canal, asignado, regla, telefonoCliente, chatId,
  ultimoMensajeAt, candidatos[], actualizadoAt, version} solo si cambió; nunca borra lo ya guardado
lotes de 400 escrituras

Es un campo de primer nivel para que sobreviva a las escrituras de la agenda.

11.5 Alerta de llegada tardía (functions/alertas-llegadas.js)

alertasLlegadas: cada 5 minutos de 5:00 a 22:55, zona Costa Rica. Es informativa: da visibilidad, no castiga.

esperados(fecha):
  técnicos: hora de convocatoria de su ruta en el ruteador del día (sin Ruta 20)
  ventas: rol de la semana (roles_ventas/{lunes}): diurno 8:00, temprano 7:00, nocturno 12:00,
          domingo 8:00, sábado tarde 14:00 los sábados; solo los días que el rol trabaja
  gerencia, administración y bodega no se avisan
entradas = primer startTime en work_sessions dentro de la ventana
           (una sesión hasta 4 h antes de la hora esperada cuenta como "madrugó"; antes de eso es otro turno)
por persona:
  tarde = minutoActual - esperado ; si < 0: aún no
  si ya marcó y se le avisó antes: aviso verde "inició turno a las HH:MM (N min tarde)" una vez
  si no marcó: el escalón más alto alcanzado y no enviado de [6, 11] minutos (amarillo, rojo)
rastro: alertas_llegadas/{fecha}_{username} {esperadoMin, enviados[], llegoAvisado, llegoMs}
envío: al grupo personal de WhatsApp de cada uno con su @ (gerencia está en todos); sin grupo, al grupo de alertas;
       los avisos de "sin marcar" llevan imagen fija; el verde solo texto

Hasta el 21-oct-2026 los escalones eran 0, 5 y 10 minutos. Configuración en alertas_config/llegadas.

11.6 Alertas del GPS de los vehículos (functions/alertas-gps*.js)

Los vehículos tienen rastreadores que guardan lotes de puntos en flota_tracks cada unos 2 minutos.

  • alertasGpsOnTrack (onWrite de flota_tracks): detecta episodios de velocidad.
  • alertasGpsBarrido (cada 5 minutos): cierra episodios de vehículos que dejaron de reportar y revisa rutas terminadas.
  • alertasGpsEnviar (onCreate de alertas_gps): arma la imagen y la encola para WhatsApp.
Regla Valor
Velocidad Más de 90 km/h sostenido 20 s, o pico directo de 100; dos o más puntos confirmados por la distancia recorrida; más de 180 km/h es error del aparato; dos rachas a menos de 10 min son la misma
Parada fuera de ruta 20 min o más (detección con 10 min en un radio de 60 m y 3 puntos), a más de 500 m de cualquier cita del día y fuera de la central (500 m)
Gasolinera Parada a menos de 100 m de una gasolinera (lista de un mapa libre) no avisa salvo que dure más de 25 min
Cierre de ruta El vehículo volvió a la central al menos 15 min, o 90 min si quedaron citas sin visitar

La alerta de velocidad espera la confirmación de quién manejaba antes de enviarse.

11.7 Respuestas en el grupo de coordinación

coordinacionRespuestas (cada 15 minutos) mide cuánto tarda el equipo en responder las etiquetas (@) que ponen los técnicos en el grupo de coordinación de ruta (regla: toda etiqueta de un técnico se responde, al menos "Recibido", citando el mensaje). Fuente: mensajes que el agente de WhatsApp guarda con autor, menciones y mensaje citado. Resultado diario en coordinacion_respuestas/{fecha} con totales, desglose por etiquetado, por quien respondió y por técnico.

11.8 Geografía en el servidor (functions/geo.js)

  • Mapa de distritos precalculado (distritos-cr.json, unos 1,5 MB) con caja y centroide por polígono, para que el punto en polígono solo corra sobre los polígonos cuya caja contiene el punto. Nombres con tildes en ubicaciones-cr.json (árbol provincia → cantón → distrito con ids).
  • Límites aceptados: latitud 8,0 a 11,2 y longitud −85,9 a −82,5.
  • Si un punto no cae en ningún polígono (costa, mar frente a la playa) se toma el distrito más cercano hasta 3 km.
  • Texto libre: normalización con alias coloquiales, búsqueda de distrito por nombre y geocodificación con un servicio público de mapas (1 llamada por segundo). Enlaces cortos de mapas se resuelven siguiendo la redirección.
  • Cobertura: atlas_zonas (caché 10 min) con punto en polígono. Si el atlas no se puede leer o está vacío se responde "cubierto": mandar a humano a todos los leads por una falla de lectura sería peor que atender uno fuera de zona.
puntoEnPoligono(lat, lng, poligono):        // ray casting
  dentro = false
  para cada arista (i, j):
     si (yi > lat) != (yj > lat) y lng < (xj - xi) * (lat - yi) / (yj - yi) + xi: dentro = !dentro
  return dentro

12. Logística, mapas y geografía (pantallas)

12.1 Compás: optimizador de huecos (compas.html)

Propósito: elegir día, ruta y hora para una cita nueva de modo que el técnico maneje lo menos posible, agrupando citas cercanas. Desde el 29-oct-2026 es la forma normal de agendar (el arrastre en el cronograma y el botón Clonar llevan aquí). Lo usan vendedores y coordinación. espacios_ruta.html y mapa_agenda.html redirigen aquí.

Entrada: GPS pegado, enlace de mapas, clic en el mapa o teléfono del cliente (toma la ubicación de su última cita con GPS y muestra última visita, número de servicios y si ya tiene cita futura). Filtros: duración, turno (D o N), días hacia adelante, fecha exacta, día de la semana (mira al menos 3 semanas), mañana o tarde (corte a las 12:00 en diurnas y a las 19:00 en especiales), ruta específica.

Modelo de manejo:

manejo(a, b) = km(a, b) < 0,05 ? 0 : 6,2 + 1,83 * km_en_línea_recta(a, b)     // minutos
   calibrado con 2.387 tramos reales medidos por el servicio de direcciones (error típico 2,5 min)
factorPico(hora) = 1,35 entre 6:30 y 9:00 ; 1,3 entre 16:00 y 19:00 ; si no 1   (supuesto, no calibrado)
holgura(m) = m > 0 ? 5 + 0,2 * m : 0      // a cada lado de la cita, para imprevistos
ventanas: diurno [7:00, 17:00] ; especial [15:00, 26:00 (2 a. m.)]

Lectura de ocupación: citas del rango (sin Ruta 20 ni canceladas), ruteadores del rango (técnicos y bloqueos, con bloqueos de madrugada pasados a +24 h) y propuestas del bot elegidas.

huecosDeRuta(rd, X, dur, turno, minimoInicio, ventana):
  nodos = citas ordenadas; una cita sin GPS toma el punto de la anterior con GPS (o la central)
  para cada hueco entre prev y sig (incluye antes de la primera y después de la última):
     ida    = manejoA(prev o central → X, a la hora en que termina prev)
     vuelta = manejoA(X → sig o central, a la hora en que debe llegar a sig)
     desde  = prev ? fin(prev) + ida + holgura(ida) : inicioVentana
     limite = sig ? inicio(sig) - vuelta - holgura(vuelta) : finVentana
     desde  = max(desde, inicioVentana, minimoInicio)       // hoy: nada antes de ahora + 60 min
     inicio = redondear arriba a 15 ; saltar bloqueos del ruteador (hasta 4 veces)
     ultimoInicio = redondear abajo a 15 de (min(limite, finVentana) - dur)
     si cabe:
        sugerida = inicio, salvo antes de la primera: lo más tarde posible (pegada en el tiempo)
        costo = sin citas: 15 (umbral de grupo nuevo)
                entre dos: ida + vuelta - manejo(prev → sig)     // desvío real
                antes de la primera: vuelta ; después de la última: ida
        // la salida y el regreso a la central NO cuentan: ese viaje se hace siempre

Con 2.491 citas de dos meses simuladas en orden de creación a ±2 días, este criterio bajó 23 % el manejo entre citas contra lo real.

Resultados:

  • Solo rutas que salen ese día (con citas o técnico) y del turno elegido.
  • Colores: hasta 10 min de desvío "bueno", hasta 25 "medio", más "malo".
  • "Para ofrecerle al cliente": 5 opciones, primero el hueco más barato de cada día distinto, luego los siguientes sin repetir ruta en el mismo día. Botón Copiar arma el mensaje con las 3 mejores como se le dice al cliente.
  • Día por día: el mejor hueco de cada ruta, con "ver más".
  • "Abrir una ruta adicional": cuando nada sirve, 2 opciones de rutas que ese día no salen (sin citas, sin técnico ni bloqueos), nunca en domingo salvo pedido expreso; el técnico se asigna después en el ruteador.
  • Al elegir una opción: timeline del día con la cita nueva en su hueco (movible de 15 en 15 dentro del rango que respeta manejo y holgura), mapa con los tramos nuevos en rojo y la salida y regreso a la central punteados.
  • Al confirmar abre la agenda con nuevaCita=1&hora&ruta&duracion&modalidad=optimizador_algoritmo&origen=compas&lat&lng.

Reacomodos sugeridos (con la cita nueva puesta):

para cada cita c de OTRA ruta del mismo día y turno, a 6 km o menos de la nueva:
  ahorro = lo que su ruta deja de manejar sin ella (desvío)      ; exigir >= 10 min
  mejor hueco para c en esta ruta (con la nueva incluida)
  neto = ahorro - costo del hueco                                 ; exigir >= 6 min
mostrar los 3 de mayor neto; mover la cita es de quien la vendió o con su autorización

Probado con días reales: las citas que estorban ahorran 10 a 19 min al sacarlas pero meterlas cuesta 7 a 13, neto típico 3 a 6.

Compactar rutas (próximos 7 días):

para cada cita c con GPS (sin ficticias, sin propuestas, no pasada):
  ahorro en su ruta A ; exigir >= 8 min
  para cada otra ruta B del mismo turno y día: costo geométrico de insertarla en su mejor lugar
  mejor B con neto >= 8 y la vecina a 5 km o menos (lejos de todo no es un reacomodo)
  estrella si en B hay hueco a la MISMA hora ±90 min (se mueve sin renegociar con el cliente)
ordenar: primero con estrella, luego por ahorro; una cita se sugiere una vez; una cita que sirve de ancla
de otra sugerencia no se propone mover
mapa: ruta de origen fina, ruta destino gruesa, flecha animada de la cita a su vecina nueva

"Aprovechar el viaje": garantías por atender y clientes inactivos de la cartera que caen cerca de la ruta elegida. Desde la lista se puede agendar una garantía en un hueco de 30 min pegado a la cita más cercana y armar el WhatsApp de confirmación en el teléfono donde ya está el chat (sin la palabra garantía ni promesas).

12.2 Logística (logistica.html)

Herramienta anterior a Compás para encajar trabajo nuevo en días futuros. Fijar un punto en el mapa → buscar citas dentro de un radio (5 km por defecto, de 1 a 25) en los próximos 21 días (solo posteriores a hoy), ordenadas por distancia (verde hasta 2 km, ámbar hasta 5) → elegir día y ruta → timeline compacto con huecos libres de al menos 45 min → arrastrar sobre el timeline para fijar inicio y duración (un toque vale 1 hora, mínimo media hora) → abre la agenda prellenada con modalidad mapa_logistica. Usa distancia en línea recta a propósito: el tiempo real de manejo cuesta dinero por llamada y para ordenar candidatos alcanza.

12.3 Mapa de rutas del día (mapa_rutas.html)

Vista de solo mapa de las rutas de un día, pensada para el teléfono desde los enlaces cortos del grupo Ventas (/m/AAAA-MM-DD/d diurnas o /n nocturnas). Barra de rutas (tocar una deja solo esa), ficha de cita al tocar un punto (igual a la Vista Ventas), tiempos libres entre citas apagados por defecto (preferencia local), zoom mínimo 11 en la mediana para que una cita lejana no encuadre todo el país.

12.4 Agenda de ruta (timeline-ruta.html)

Timeline de una ruta y un día (50 px por hora, 18 horas) con arrastrar y soltar para mover citas y colocar una cita nueva desde la búsqueda por proximidad. Escribe por ruta de campo, recalcula fecha_dia y fecha_mes por día operacional, guarda metadata.ruta como texto "Ruta N" (antes quedaba el número suelto) y pasa por el portero de especiales alistados.

12.5 Supervisor de puntualidad (reporte_puntualidad.html)

mes operacional: del día 1 a las 5:00 al día 1 siguiente a las 5:00
convocatoria por técnico y fecha = la más temprana de las rutas donde aparece (ruteador.horaConvocatoria)
entrada = primer startTime de work_sessions del día operacional (sin turnos anulados)
diff = entrada - convocatoria (minutos)
categoría: sin sesión → ausencia ; diff < -10 → temprana ; diff <= 5 → en hora ; si no → atraso
puntualidad = (díasConEntrada - atrasos) / díasConEntrada   // las ausencias no cuentan como puntuales
colores: >= 90 % verde ; >= 60 % amarillo ; si no rojo
"ahorro potencial" = suma de minutos de llegada demasiado temprana

Solo días pasados. La edición de la hora de entrada por un supervisor queda en work_sessions_logs (misma bitácora que la pantalla de edición de horas). Lección: contar las ausencias como puntuales inflaba el porcentaje.

12.6 Citas sin ruta (reporte_sin_ruta.html)

Lista las citas del mes con ruta vacía o "Sin asignar" (excluye reservas: nombres con "reserv", "no tocar", "apartad" o teléfonos de relleno). Única escritura: anular el monto (solo a ₡0, solo citas sin ruta, relee la cita antes, firmado en el historial) por la función cambiarMontoCita. El botón aparece con el permiso anular_monto_cita o gerencia, resolviendo el cargo desde la ficha completa del empleado (el objeto de sesión no trae departamento ni cargo; probando en vivo ni gerencia veía el botón).

12.7 Mapa de inteligencia de clientes (mapa.html + js/mapa.js)

Buscador de direcciones por teléfono, análisis de citas por periodo (por defecto los últimos 3 meses; antes leía 8.000 citas y filtraba en el navegador): mapa de calor, rankings por provincia, cantón y distrito, Pareto (cuántos territorios hacen el 80 % de citas y monto), citas por semana y mini mapas de los 5 primeros territorios. Excluye canceladas (Ruta 20).

12.8 Zonas, atlas y ubicaciones

  • Atlas (atlas.html): editor de polígonos de zonas de servicio en atlas_zonas {nombre, color, poligono[{lat,lng}], createdAt, updatedAt} (Firestore no admite arreglos anidados, se guardan objetos; se leen ambos formatos). Modo dibujo (clic agrega vértice, clic en el primero cierra) y modo edición (arrastrar vértices, insertar desde el punto medio, el vértice nuevo va en el segmento que menos perímetro agrega). Guardado en segundo plano; si falla, la zona se repinta punteada y se avisa (antes se veía igual que una guardada). Las zonas sirven para la cobertura del bot, la zona de cada ruta en el ruteador y el puntaje de Optimus.
  • Clustering (zonas_clustering.html): propone zonas a partir de las citas de varios meses. Proyección local equirrectangular en metros; filtro de densidad (radio 3.000 m, al menos 5 vecinos; las aisladas se pintan aparte); k-means++ con k = 8 por defecto (2 a 20) y 20 reinicios quedándose con la menor inercia; casco convexo (cadena monótona) por cluster y centroide.
  • Ubicaciones frecuentes (ubicaciones_frecuentes.html): catálogo ubicaciones_frecuentes {nombre, lat, lng, comentarios} (importable por CSV) para elegir GPS por servicio; sugiere nuevas agrupando puntos históricos de citas a 150 m.
  • Mapa de ubicaciones (ubicaciones_mapa.html): citas del mes de un cliente en mapa, pendientes sugeridos por última visita, edición del reporte desde oficina y generación del reporte consolidado con clave (9.5).
  • Revisión de ubicaciones (revision_ubicaciones.html): solo lectura. Encuentra citas cuyo rótulo de provincia, cantón y distrito no calza con el punto real (punto de la sucursal si existe, si no el de la cita) usando el KML de distritos. No corrige en bloque: son citas viejas, algunas ya facturadas y reportadas.
  • Correcciones GPS (reporte_correcciones_gps.html): lista las coordenadas_corregidas de los técnicos con podio por empleado; permite aprobar, rechazar o borrar (y borra la foto de Storage).

12.9 Otras pantallas

  • Dónde está el equipo: última posición de quien tiene turno abierto. Fuente principal dim_location (cada carga de página, en vivo); respaldo gpsStart de la sesión, dibujado punteado. Umbrales de frescura de 10 minutos y 1 hora. No usa el rastreo continuo de la app nativa porque casi nadie la tiene corriendo.
  • Diagnóstico de solapes (diagnostico_overlap.html): timeline por ruta de 5:00 a 22:00 a 3 px por minuto para revisar choques.
  • GPS 360: vista única del GPS de los vehículos con recorrido, paradas y su cruce con las citas del día (detalle en el capítulo de flota).

13. Resumen de procesos programados y disparadores

Función Disparador Horario o evento Efecto en la operación
citaOverlapCheck onCreate citas Al crear Marca choques
ventasAutoEnviar onCreate citas Al crear, espera 3 min Pantallazo al grupo Ventas
stampClienteOnCita, metaCapiOnCita, llamGCompraOnCita, recuperadorCarteraOnCita onWrite citas Cada escritura Totales por cliente, conversiones, CRM, cartera
garantiaRefuerzoOnCita onWrite citas Cada escritura Garantías, tickets rojos
portalPushAlCerrarReporte onWrite citas Si cambia el reporte Portal del cliente
revisarReporteRojo onWrite reportes_publicos Al crear o rehacer Revisión IA de la nota
ticketsRojoVerificar Schedule Cada 10 min Cierra tickets enviados o con refuerzo
confirmacionAviso12 / 14 Schedule 12:00 y 14:00 Pendientes de confirmar y aviso
confirmacionVencida1530 Schedule 15:30 Venta no comisionable
waCoordinacionCitasHoraria Schedule Minuto 20 de cada hora Línea de WhatsApp por cita
alertasLlegadas Schedule Cada 5 min, 5:00 a 22:55 Llegada tardía
alertasGpsOnTrack, alertasGpsBarrido, alertasGpsEnviar onWrite, schedule 5 min, onCreate Continuo Velocidad y paradas
coordinacionRespuestas Schedule Cada 15 min Tiempo de respuesta a técnicos
garantiaAvisoRespuesta onUpdate garantias Cliente envía evidencia Aviso al grupo
garantiasResumenDiario Schedule Lunes a sábado 8:00 Pendientes de garantía
satisfaccionClientes Schedule 4:30 diario Clasificación de satisfacción
cambiarMontoCita, enviarCitaAVentas, ventasPantallazo, reporteCliente, revisarJustificacionSinOptimus, pulirComentarioReporte, generarComentarioConsolidado, movimientoInventario HTTP A pedido Ver secciones

Todos los mensajes de WhatsApp de estos procesos se encolan en wa_equipo_salida y los despacha un agente propio conectado a los teléfonos de la empresa; la línea oficial del CRM usa la API oficial de mensajería con plantillas aprobadas.


14. Lo que no se pudo verificar

  • No se encontró ningún proceso actual que escriba metadata.estado distinto de agendada; puede existir en scripts sueltos o páginas no revisadas.
  • generarCronograma en detalle (cómo arma los bloques de rutas para la asignación automática y si Ruta 20 entra en el bloque nocturno) y los manejadores de soltar recursos línea por línea.
  • El recálculo incremental de traslados y la llamada exacta al servicio de direcciones.
  • El contenido real en la base de catálogos (servicios, productos, herramientas_alisto, canales) y de configuraciones (configuracion/rutas, alertas_config/*, wa_equipo_config/*).
  • Si las funciones HTTP mencionadas están todas desplegadas (por ejemplo cambiarMontoCita; si no lo está, el respaldo del navegador solo funciona para gerencia).
  • La reversión de confirmaciones vencidas de fines de octubre y sus cifras salen de la documentación del proyecto, no del código.
  • Las reglas de storage.rules por carpeta (reportes/, vauchers/, recibos_efectivo/, ubicaciones/); solo se vio la validación genérica de tipo y tamaño (menos de 60 MB).
  • Las colecciones ruteador, ruteador_dias, ruteador_historial y atlas_zonas no tienen reglas propias y caen en el comodín de empleados; las restricciones de ruteador (días pasados, candado, borrar bloqueos) son solo de pantalla.
  • La primera lectura de encuesta.html sin sesión y la consumición real de wa_equipo_salida por el agente de WhatsApp.
  • Las pruebas de reglas con el emulador no se ejecutaron: todo lo de reglas es lectura del código.
03

Ventas, CRM y clientes

Este capítulo describe todo lo que pasa entre que un posible cliente escribe por primera vez y el servicio postventa: la línea oficial de WhatsApp (API de Meta), el tablero de vendedores ("embudo"), el reparto automático de leads, las reglas de comportamiento que el servidor hace cumplir, los vendedores virtuales (bot), los teléfonos de empresa conectados por WhatsApp Web, la cartera de clientes corporativos con su portal, y los módulos de postventa (garantías, tickets de reporte grave, satisfacción, recuperación de clientes dormidos).

Convenciones del capítulo:

  • Hora: todo el servidor trabaja en hora de Costa Rica (UTC-6 fijo, sin horario de verano). Para obtener el día local se resta 6 h y se leen los campos UTC.
  • Día operacional: de 5:00 a. m. a 4:59 a. m. del día siguiente. Una cita a las 2 a. m. pertenece al día anterior.
  • Moneda: colones enteros, sin céntimos.
  • Teléfono: llave universal del cliente. Se normaliza a 506 + 8 dígitos (solo dígitos; si viene con 00 se quitan; si tiene 8 dígitos se antepone 506). Es el id del documento de conversación.
  • Ruta 20: la "ruta" de las citas canceladas. Toda métrica de venta excluye Ruta 20 y monto 0.
  • Ficticia (metadata.es_ficticio): venta cerrada en ruta por un técnico; cuenta como dinero, no como visita.
  • Gerencia: un conjunto de usuarios con permiso reportes_gerenciales. Los módulos nuevos tienen clave de permiso propia que hereda de esa.
  • Usuario: el username del empleado sale del correo corporativo sin el dominio. Los bots tienen username terminado en .bot.
  • Toda función HTTP valida el idToken de Firebase y exige que el empleado tenga ficha (empleados_uid_lookup/{uid}); devuelve username, permisos con herencia y si es gerencia.

Mapa del área

Bloque Pantallas Servidor Colecciones principales
WhatsApp oficial embudo, central en WhatsApp de empresa whatsappWebhook, whatsappSend, descargarMediaWA, plantillas, llamadas whatsapp_conversations, whatsapp_messages, whatsapp_calls, whatsapp_call_permissions
Tablero de ventas embudo_vendedor.html reparto, lead frío, reclamos whatsapp_conversations, whatsapp_activity, llamadas_documentadas
Reparto panel en el embudo, reasignar-leads.html, horarios_ventas.html repartirLeads, preverReparto, nivelación, turno whatsapp_autoassign_config/*, reparto_nivelacion, roles_ventas
Fuentes de leads sitio web, Messenger, B2B webClick, webLead, sincronizarMessenger web_clicks, gads_clicks, conversaciones fb_*
Bot de ventas bot_ventas.html botVentasOnInbound, seguimientos estado del bot dentro de la conversación
WhatsApp de empresa whatsapp_empresa.html agente en servidor propio + funciones de medición wa_equipo_*
Medición del equipo conversión, eficiencia, mini táctico, mis ventas tiempos de respuesta, snapshot diario tiempos_respuesta, snapshots_ventas, tactico_ventas_cache
Clientes y postventa cartera.html, portal, garantías, satisfacción, recuperador, lealtad triggers sobre citas portal_*, garantias, tickets_rojo, satisfaccion_*, recuperador_cartera

Ciclo de vida de un lead

                     ┌──────────────────────────────────────────────────────────────┐
 1. LLEGA            │ Cliente escribe a la línea oficial (o Messenger, o desde el    │
                     │ sitio con un código W/G en el texto)                           │
                     └──────────────────────────────────────────────────────────────┘
   whatsappWebhook crea whatsapp_messages/{wamid} (direction=inbound, status)
   y crea/actualiza whatsapp_conversations/{tel}:
     phone, name, whatsappName, lastMessage, lastMessageAt, lastMessageDirection="inbound",
     lastInboundAt, unreadCount+1, windowExpires (+24 h, o +72 h si vino de anuncio),
     source, subSource, sourceAt, clienteAlEntrar   ← primer toque, CONGELADO
     esCliente/totalFacturado (de analitica_clientes), boardCol="__sin_asignar__"
     (si es aspirante a empleo: assignedTo=reclutamiento, boardCol="__empleo__")
     (si vino de anuncio: evento "Contact" a la API de conversiones)
                                   │
                                   ▼
 2. BOT (opcional)   botVentasOnInbound: si el lead está en modo bot, assignedTo="<bot>.bot",
                     el bot contesta; al pedir humano o al agendar → handoff (assignedTo humano)
                                   │
                                   ▼
 3. SE ASIGNA        embudo detecta lead sin dueño, espera 6 s, llama repartirLeads:
                     assignedTo=<vendedor>, assignedAt, boardCol=<vendedor>, embudoVendedor=""
                     trigger estamparPrimerAsignado → primerAsignado, primerAsignadoAt,
                     primerAsignadoFuente (no cambia nunca más)
                     libro mayor de 72 h: buckets[vendedor][hora]++
                                   │
                                   ▼
 4. SE RESPONDE      whatsappSend (texto dentro de 24 h, plantilla fuera):
                     guardas: autor del token, 60/min, política de promesas,
                     aceptación de política, llamadas vencidas, opt-out (solo plantillas)
                     → whatsapp_messages (status "sent" → "delivered" → "read" por webhook)
                     → si el dueño no está en turno y el cliente fue el último: assignedTo=autor,
                       reasignadoTurno{de,a,at,rolDe,rolA}
                     → Google/Directo/Sitio: llamGInicio, llamGVence = +3 h, llamGResp
                     → lastMessageDirection="outbound", lastMessageAt
                     tiempos_respuesta/{día} mide minutos hasta esta respuesta
                                   │
                                   ▼
 5. LLAMADA          vendedor llama (WhatsApp o celular) y la documenta:
                     llamadas_documentadas/{id}, llamDocHecha, llamDocAt, llamDocN
                     si era obligatoria: llamGHecha, llamGResultado
                     sin documentar > 2 h del vencimiento → whatsappSend devuelve 423
                                   │
                                   ▼
 6. SE COTIZA        plantilla de cotización (templateName en el mensaje) y/o
                     embudoVendedor="__cotizaciones__" (columna final), followUpAt
                                   │
                                   ▼
 7. SE AGENDA        se crea citas/{id} (metadata.vendedores, metadata.monto, telefono_normalizado,
 (COMPRA)            metadata.canal). Triggers:
                     stampClienteOnCita → analitica_clientes{total_facturado,total_citas},
                        conversación esCliente=true, totalFacturado, boardCol="__clientes__"
                     llamGCompraOnCita → llamGCompro, llamGComproAt
                     ventasAutoEnviar → imagen de la venta al grupo de ventas (3 min)
                     ventasSync (6 h) → ventas_atribuidas/{citaId} + evento Purchase
                     portalPushAlAgendar (si es cliente de cartera) → visita en el portal
                                   │
             ┌─────────────────────┴──────────────────────┐
             ▼                                            ▼
 8a. POSTVENTA                                  8b. SE ENFRÍA
   reporte del técnico → portal, tickets_rojo     sin mensajes > 3 días:
   (si condición roja), satisfaccion_citas,         plantilla de recontacto ("raspa"):
   garantias (si reclama), whatsapp_coordinacion     embudoVendedor = __raspa1__ → __raspa2__
   a los 183 días sin volver →                       → __raspa_def__ → __raspa_no_mas__
   recuperador_cartera (estado disponible)          si otro vendedor lo revive: reasignarLeadFrio
                                                     (assignedTo = quien raspó)
                                                    archived=true / __basurero__ / __no_compro__
                                                    opt-out (botón "No deseo más mensajes"):
                                                     optOut=true → no más plantillas

Qué campo decide en qué columna del tablero aparece el lead (boardCol, desnormalizado y recalculado por el servidor):

columnaDelTablero(c):
  si c.embudoVendedor es una columna final → esa
  si (c.esCliente o c.esClienteManual) y no c.fueraDeClientes:
       "__recuperador__" si c.recuperadorEnviado, si no "__clientes__"
  si no hay c.assignedTo → "__sin_asignar__"
  si no → c.assignedTo

Columnas finales (orden del tablero): Empleo, Empleo prospecto, Empleo descartado, Rural, Recuperador 3000, Garantías, Cotizaciones, Raspa 1, Raspa 2, Raspa definitivo, No raspar más, Leadsales, Recuperador (categoría), Basurero, No compró. La lista de claves finales tiene que ser idéntica en servidor y navegador: cuando al servidor le faltaron seis claves, el trigger reescribía la columna de esos leads.

WhatsApp oficial: arquitectura

  • Una sola línea oficial de WhatsApp Business (Cloud API de Meta). El webhook entra por una Cloud Function HTTP. Los envíos salen por otra Cloud Function que llama a Graph.
  • El estado vive en Firestore. La colección whatsapp_conversations tiene un documento por teléfono (id = teléfono normalizado). La colección whatsapp_messages tiene un documento por mensaje.
  • Messenger reutiliza las mismas colecciones con id fb_<psid> (y está previsto ig_ para Instagram). Un helper esIdMeta(id) desvía esos casos. Todo código nuevo que use el id de conversación como teléfono tiene que respetar ese desvío.
  • Versiones de Graph en uso: media y mensajes v23, marcar como leído v21, llamadas y permisos v24, plantillas v22. Al reconstruir conviene unificarlas.
  • Secretos usados: token de acceso de WhatsApp, app secret (para la firma), verify token, id del número de teléfono, token y id de la página de Meta (Messenger) y la llave del proveedor del modelo de lenguaje. Todos son secretos de Functions. El id de la cuenta WABA está fijo en el código y el que mande el cliente se ignora.
  • Autenticación de endpoints: verifyEmpleado(req) valida el idToken de Firebase y exige que exista la ficha empleados_uid_lookup/{uid}. Devuelve username, permisos (con herencia) y esGerencia. El username sale del correo de la cuenta. Los permisos se piden con exigirPermiso(authUser, [claves]).
  • CORS restringido a una lista de orígenes (CORS_OPTIONS).

Normalización de teléfono (normalizePhone)

p = solo dígitos
si empieza con "00": quitar los dos ceros
si tiene 8 dígitos: anteponer el código de país 506

Limitación conocida: un número extranjero de 8 dígitos también recibe el 506. Sin quitar el 00, un mismo número pegado como 00506... abría una conversación paralela. Para el cruce con clientes existe otra llave, shared/telefono.normalizar.

WhatsApp oficial: modelo de datos

whatsapp_conversations/{telefono}

Grupo Campos
Identidad phone, name (nombre editable en el CRM, se fija al crear con el nombre del perfil), whatsappName (nombre del perfil, se actualiza en cada entrante), createdAt (solo en el alta por llamada)
Último mensaje lastMessage (100 caracteres), lastMessageAt, lastMessageDirection (inbound/outbound), lastInboundAt, updatedAt, unreadCount (con increment)
Ventana de 24 h windowExpires, windowOrigin (user_initiated, referral_conversion o el origen que mande Meta), conversationId
Estado archived (se apaga solo cuando el cliente vuelve a escribir), optOut, optOutAt (los escribe el bot de ventas)
Canal, primer toque congelado source (ad, google_ads, youtube, bing, tiktok, sitio_web, directo, empleo, proveedor, saliente, messenger), subSource, sourceAt, clienteAlEntrar, directoTipo, sourceDetalle (llamada)
Anuncio de Meta metaPlataforma (facebook, instagram, whatsapp u otro, según la URL del referral), adId (ctwa_clid), adHeadline, adBody, adSourceId, adCampaignName, adCampaignId, adName (mapeo desde meta_ad_mapping/{source_id})
Sitio y Google googleAdsRef (G-xxxx), gclid, wbraid, webRef, webRefUltimo, campos de visita (webSubcanal, webPlaga…), webSeguimientoPendiente, webLeadAt
Cliente esCliente, totalFacturado, clienteStampAt, esClienteManual (lo marca gerencia), fueraDeClientes, recuperadorEnviado
Asignación assignedTo (username, o *.bot si es el bot), assignedAt, primerAsignado, primerAsignadoAt, primerAsignadoFuente, embudoVendedor (columna especial __x__ o vacío), boardCol (columna desnormalizada), reasignadoTurno {de, a, at, rolDe, rolA}, fueraDeReparto
Llamadas de WhatsApp lastCallAt, lastCallStatus, lastCallId, llamadaPendiente, llamadasPerdidas
Llamada documentada llamDocHecha, llamDocAt, llamDocPor, llamDocN, llamAsignada, llamAsignadaAt, llamAsignadaPor
Llamada obligatoria llamGInicio, llamGVence, llamGResp, llamGHecha, llamGAt, llamGPor, llamGSeg, llamGResultado, llamGFuente, llamGCompro, llamGComproAt, llamGExento, llamGExentoMotivo, llamGExentoPor, llamGExentoAt
Otros que leen los módulos tags, followUpAt, empleoJaladoAt

boardColFor(c) tiene que coincidir exactamente con la función del tablero en el navegador:

si embudoVendedor está en BOARD_FINAL_KEYS: devolver embudoVendedor
si (esCliente o esClienteManual) y no fueraDeClientes:
    devolver "__recuperador__" si recuperadorEnviado, si no "__clientes__"
si no hay assignedTo: devolver "__sin_asignar__"
si no: devolver assignedTo

Las claves finales son: empleo y sus dos subcategorías (prospecto y descartado), rural, recuperador, garantías, cotizaciones, raspa1, raspa2, raspa_def, raspa_no_mas, leadsales, cat_recuperador, basurero y no_compro. Si a la lista del servidor le falta una clave, el trigger le reescribe la columna al lead (ya pasó con seis claves).

whatsapp_messages/{id}

  • Id del documento: el wamid en los entrantes (eso da idempotencia). Un id automático en los salientes de whatsappSend. El wamid en el pedido de permiso. call_<callId> en el rastro de llamada. El mid en Messenger.
  • Campos comunes: conversationPhone, from, to, type, body, mediaUrl, mediaId, direction, status, timestamp, wamid, contactName, sentBy (solo salientes), replyTo, fileName, mimeType, voice, latitude, longitude, locationName, locationAddress, sharedContacts, buttonPayload, interactiveId, interactiveType, interactiveDescription, callPermission, reactedMessageId, order, systemType, rawPayload (500 caracteres), forwarded y frequentlyForwarded.
  • Referral de anuncio: referralSource, referralSourceUrl, referralHeadline, referralBody, referralAdId, referralSourceId, referralMediaType, referralMediaUrl, referralThumbnailUrl.
  • Atribución: googleAdsRef, webRef.
  • Plantillas: templateName, templateUrlParam.
  • Fallos: failReason, failCode, failDetails.
  • Media: mediaPendiente, mediaError, mediaIntentoAt, mediaBarridos, mediaAbandonadaAt.
  • Borrado lógico: deleted, deletedAt, deletedBy.
  • Llamadas: callId, callStatus, callAnsweredBy, callDuration.
  • Permiso: permisoMotivo, permisoVia.
  • Otros: bot (true en mensajes del bot) y canal: "messenger".

Otras colecciones del núcleo

Colección Uso
whatsapp_calls/{callId} Estado de cada llamada
whatsapp_call_permissions/{tel} Permiso de llamada por teléfono
whatsapp_activity Bitácora: phone, action, details, performedBy, timestamp
reasignaciones_turno Registro de cada traspaso por turno
promesas_bloqueadas Intentos frenados por la política de promesas
politica_promesas/{username} Aceptación de la política, con version
llamadas_documentadas/{id} Llamadas registradas
_rate_limits Contadores de límite, con campo expira para una política TTL que se configura en consola
plantillas_stats/resumen Caché de estadísticas de plantillas
whatsapp_templates Catálogo local de plantillas
crm_atributos/{tel} y crm_meta/atributos Atributos para segmentar y su marca de avance
tiempos_respuesta/{día} Tiempos de respuesta por día
reportes_escritura/{día} Evaluación de escritura de vendedores
analitica_clientes/{tel} Cliente agregado: total_facturado, primera_cita_fecha, etc.
meta_ad_mapping/{postId} Mapeo de anuncio a campaña
gads_clicks/{G-code} y web_clicks Clics de Google y visitas del sitio
roles_ventas/{lunes} Roles de turno de la semana
whatsapp_reclamos Reclamos de asignación

whatsappWebhook (HTTP)

Configuración: máximo 10 instancias, 120 s, 512 MiB.

GET (verificación): si hub.mode == "subscribe" y hub.verify_token coincide con el secreto, devuelve hub.challenge. Si no, 403.

POST:

  1. Valida la firma x-hub-signature-256: HMAC-SHA256 del raw body con el app secret, comparado con timingSafeEqual y largos iguales. Si falla, 403.
  2. Si body.object !== "whatsapp_business_account", responde 200 y sale.
  3. El 200 va después de guardar. En Functions v2 la instancia puede quedarse sin CPU al responder, y Meta no reintenta un 200, así que antes los mensajes se perdían en silencio. Ante cualquier excepción responde 500 para que Meta reintente. Todo el procesamiento es idempotente.
  4. Las escrituras van en un batch que se confirma cada 400 operaciones.
  5. sourceDecidido es un Map por teléfono dentro del mismo POST. Sin él, un anuncio y un texto en el mismo lote dejaban el segundo pisando el canal del primero, porque la conversación se lee por mensaje pero se escribe al final.

value.statuses (estados de entrega)

  • Busca el mensaje con where("wamid","==",id).limit(1).
  • El rango es sent=1, delivered=2, read=3, failed=0. Solo actualiza si el nuevo rango es mayor o si el estado es failed. Así un delivered tardío no pisa un read.
  • En failed guarda failReason (title o message), failCode y failDetails (error_data.details).
  • Si el estado no encuentra el mensaje, se descarta. Por eso whatsappSend crea el documento del mensaje apenas responde Meta (ver 4.6).
  • Si viene status.conversation y el origen no es business_initiated, actualiza windowExpires (de expiration_timestamp) y windowOrigin, solo si la fecha nueva es mayor. Guarda conversationId.

value.messages (entrantes)

Para cada mensaje:

a) Cuerpo según el tipo:

  • text: text.body.
  • image, video, audio, document, sticker: caption o [tipo], más mediaId. Documento agrega fileName y mimeType. Audio agrega voice.
  • location: 📍 nombre, dirección o las coordenadas, más lat, lng, nombre y dirección.
  • contacts: 👤 Nombre, tel, más sharedContacts[].
  • button: button.text, más buttonPayload.
  • interactive: button_reply y list_reply (id y descripción). call_permission_reply: el cuerpo dice "Autorizó llamadas de WhatsApp", "Autorizó llamadas por 7 días" o "No autorizó". Guarda callPermission (permanente, temporal o rechazado) y llama a waPermisoLlamada.guardarRespuesta. Antes caía como [interactivo] y el permiso nunca se guardaba.
  • reaction: el emoji y reactedMessageId.
  • order: "Pedido recibido".
  • system: system.body y systemType.
  • otro tipo: [tipo] más rawPayload.

b) Referral (click-to-WhatsApp): campos referral*. ctwa_clid se guarda en referralAdId.

c) Context: replyTo, forwarded, frequentlyForwarded.

d) Marcadores del sitio: la regex Ref:? G-[a-z0-9]{3,10} llena googleAdsRef (clic pagado de Google; Google no manda referral, este marcador es la señal). La regex de web-leads Ref: W-xxxx llena webRef (visita del sitio).

e) Idempotencia: msgRef = whatsapp_messages/{wamid}. Si ya existe, no se reescribe, porque borraría el mediaUrl que puso la descarga, y tampoco se recuenta.

f) Conversación:

  • Siempre escribe whatsappName, phone y updatedAt.
  • Para todo lo que no sea reacción: lastMessage, lastMessageAt, lastMessageDirection: "inbound" y lastInboundAt se escriben solo si el mensaje es nuevo y su timestamp ≥ el lastMessageAt previo. Un reintento o un webhook fuera de orden no puede rebobinar a inbound, porque la reasignación por turno lee ese campo y otro vendedor se llevaba el lead.
  • unreadCount += 1 solo la primera vez que se guarda el mensaje (increment no es idempotente).
  • archived: false si estaba archivada. Archivar significa "ya lo atendí", no "no me escriba más".
  • Ventana: timestamp + 72 h si vino de anuncio, si no + 24 h. Se toma el máximo contra la ventana guardada (cada entrante la reinicia, pero no se acorta una de 72 h). Las reacciones no tocan la ventana.

g) Alta de conversación (lead nuevo): si el documento no existe:

  • name = contactName.
  • totalFacturado y esCliente se leen de analitica_clientes/{tel}, para que un cliente que regresa caiga al instante en Clientes.
  • boardCol = boardColFor(convUpdate), que en un lead nuevo da __sin_asignar__.
  • El webhook no asigna vendedor. La asignación la hace el reparto de servidor (wa-asignacion.js: modos por ratio de 72 h, baraja y nivelación) o el bot. La excepción es empleo (ver i).

h) Canal, primer toque congelado: el canal es asignable solo si no fue decidido en el lote y el source actual es vacío o saliente. Un saliente (contacto que iniciamos nosotros) sí se promueve al canal real cuando el cliente escribe.

  • Con referral: source = "ad", sourceAt, clienteAlEntrar, metaPlataforma (instagram.com, fb.me o facebook.com, wa.me u otro). Los campos ad* se actualizan siempre. Si campaña, titular o cuerpo calzan con la regex de empleo: source = subSource = "empleo". Hasta octubre 2026 esos aspirantes entraban como anuncio e inflaban los leads de Meta.
  • CAPI "Contact": solo en el primer guardado. event_id = contact_<tel>_<wamid>. user_data con hash SHA-256 de ph (con 506), external_id, país, nombre y apellido, más ctwa_clid. custom_data: moneda CRC, value: 0, content_category y campaign_name.
  • Sin referral, en este orden: 1. googleAdsRef presente: youtube si la visita registrada tiene utm de YouTube, si no google_ads. subSource anuncio_video_youtube o clic_pagado_google. Recupera gclid y wbraid de gads_clicks/{ref} y copia los campos de visita. 2. Visita W con gclid, wbraid o gbraid: igual, con googleAdsRef = "G-" + resto del W. 3. Visita con msclkid: bing / clic_pagado_bing. 4. Visita registrada sin click id: sitio_web, con subSource = webSubcanal. 5. Marcador W sin visita registrada (bloqueador o red): sitio_web / sitio_sin_registro. 6. Clasificación del primer mensaje (clasificarPrimerMensaje): botón de Google, empleo, Instagram, Facebook, Maps, QR, textos de anuncio. 7. "Vengo del sitio web": sitio_web / boton_sitio. 8. Si nada calza: directo, más directoTipo (precargada, saludo, no_texto o propio).
  • Comején o termita desde el sitio: webSeguimientoPendiente: true y webLeadAt, para avisar a las 48 y 72 h.
  • Si el canal no es asignable pero trae W: solo webRefUltimo.

i) Empleo: si source == "empleo", el canal es asignable y no es cliente: assignedTo = un usuario fijo de reclutamiento, assignedAt, embudoVendedor = boardCol = "__empleo__", esClienteManual: false, fueraDeClientes: true. Sin esto caía en Sin asignar y el reparto se lo daba a un vendedor.

j) batch.set(convRef, convUpdate, {merge: true}).

value.errors

Solo se registran en el log.

value.calls (llamadas de WhatsApp)

  • Documento whatsapp_calls/{callId}.
  • ¿Es saliente? Sí si el documento previo dice direction: outbound, o si call.direction == BUSINESS_INITIATED, o si el evento es connect con un SDP de respuesta: contiene a=setup:passive o a=setup:active y no contiene actpass. El SDP de un cliente que llama es una oferta (actpass). En una saliente el cliente es to, no from.
  • Saliente, y llega accept o connect con SDP: status: active, sdpAnswer, answeredAt. Sin guardar sdpAnswer, el navegador se quedaba en "Llamando..." para siempre. Registra el rastro en el chat.
  • Saliente y reject: status: rejected, endedAt.
  • connect entrante (si es saliente sin SDP, se ignora para que al vendedor no le suene su propia llamada): crea el documento con from, contactName, direction: inbound, status: ringing, sdpOffer, timestamp. Deja el rastro "ringing" en el chat y llama a marcarEntrante (la llamada del cliente abre 24 h para devolverla).
  • terminate o disconnected:
  • Si ya está en missed, rejected o no_answer, no hace nada (un reintento convertiría una perdida en "ended").
  • Estado final: ringing pasa a missed; calling pasa a no_answer; rejected se queda igual; si nunca hubo documento o estado previo, no_answer (saliente) o missed (entrante); en otro caso, ended.
  • Duración = fin − answeredAt, solo en ended.
  • Si el vendedor ya colgó desde el navegador (ended), el documento no se toca pero el rastro del chat sí se cierra.
  • Al final llama a documentarLlamadaWhatsapp.
  • Otros eventos: status = event.

registrarLlamadaEnChat

  • Escribe un mensaje con id fijo call_<callId>, type: "call", status: delivered, callStatus y su texto ("Llamada perdida", "Llamada contestada por X · m:ss", "Llamada saliente sin respuesta", etc.).
  • En la conversación: lastMessage = "📞 ...", lastCallAt, lastCallStatus, lastCallId.
  • Perdida entrante: unreadCount +1, llamadasPerdidas +1, llamadaPendiente: true. Una ended la deja en false.
  • Si alguien llama sin haber escrito nunca, crea el contacto: source: directo, sourceDetalle: llamada, sourceAt, clienteAlEntrar, boardCol.
  • Si falla, no tumba el webhook.

documentarLlamadaWhatsapp

Se documenta si hubo conversación (ended con duración > 0) o si fue una saliente sin respuesta (no_answer o rejected). Una entrante perdida no cuenta.

  • Id fijo llamadas_documentadas/wa_<callId>; si ya existe, no hace nada.
  • Campos: tipo: whatsapp, resultado (vacío si hubo conversación, para que el vendedor lo elija; no_contesto si no), prueba: "Registro de la llamada de WhatsApp (Meta)", inicio, contestada y fin, y google si tenía llamada obligatoria pendiente.
  • En la conversación: llamDocHecha, llamDocAt, llamDocPor, llamDocN +1. Si tenía la obligatoria pendiente también llamGHecha, llamGAt, llamGPor, llamGSeg, llamGResultado (o pendiente) y llamGFuente: whatsapp.
  • La llamada por WhatsApp es la prueba: no hace falta pantallazo.

descargarMediaWA (trigger al crear whatsapp_messages/{msgId})

Configuración: retry: true, 120 s, 512 MiB.

  1. Si el mensaje no tiene mediaId, sale (el 95 % de los mensajes no trae media).
  2. Relee el documento. Con retry el snapshot que llega es siempre el de la creación. Si ya tiene mediaUrl, sale, para no subir el archivo dos veces.
  3. bajarMediaWA: - GET a Graph /{mediaId} para obtener url y mime_type. - GET a esa url con Bearer. - Audio ogg o webm se transcodifica a MP3 con ffmpeg (libmp3lame, -q:a 4, 30 s). Si falla, se guarda el original: el transcode es una mejora, no un requisito. - Sube a Storage en la ruta determinista whatsapp_media/{tel}/{docId}_{mediaId}.{ext} con un token de descarga. Reintentar sobrescribe el mismo objeto en vez de dejar huérfanos.
  4. Errores: red, 429, 5xx y fallas de Storage son transitorios y se relanzan para que Firebase reintente. Los 4xx son definitivos (media vencida o sin permiso; reintentarlos sería un bucle de días). - Todo fallo anota mediaPendiente: true, mediaError y mediaIntentoAt. - Si el evento tiene más de 6 h (MEDIA_HORAS_REINTENTO), ya no se relanza. - El éxito borra mediaPendiente y mediaError.

Barrido barrerMediaPendiente (dentro de la corrida nocturna de atributos):

  • where mediaPendiente == true limit 50. Es una igualdad simple, no necesita índice.
  • Trabaja con presupuesto de tiempo (máximo 120 s).
  • Después de 5 barridos (mediaBarridos) se abandona: mediaPendiente: false y mediaAbandonadaAt.
  • Meta conserva el archivo 30 días, así que una pasada por noche alcanza.

whatsappSend (HTTP POST)

Configuración: 10 instancias, 180 s, 1 GiB.

Body: to, type, text, template{name, language, components}, templateBody, templateUrlParam, mediaUrl, mediaType, caption, fileName, sentBy, replyToWamid, emoji, reactMessageId, lat/lng, nombre y dirección de ubicación, contactName, contactPhone.

Guardas en orden exacto

  1. Método distinto de POST: 405.
  2. verifyEmpleado falla: 401.
  3. Sin to: 400 "Destinatario requerido".
  4. Autor. Sale del token, no del body. Si sentBy es distinto del usuario del token, solo se acepta cuando quien llama es gerencia (las pantallas de campaña eligen a quién abonar el contacto). Si no, se ignora y queda en el log. Antes cualquiera firmaba a nombre de otro, y los envíos masivos salían todos a nombre de una sola persona.
  5. Límite: 60 mensajes por minuto por uid, en transacción sobre _rate_limits/wa_{uid}_{minuto} (con expira a +24 h). Si se pasa: 429.
  6. El phone se normaliza, salvo que esIdMeta(to) (Messenger), donde se deja tal cual.
  7. Si type !== "reaction": - a. Política de promesas sobre textosDelEnvio: text, caption, contactName, locationName, locationAddress, templateBody y cada parameters[].text de los componentes de la plantilla. Vale para todos, gerencia incluida. Si bloquea: registra en promesas_bloqueadas y responde 422 codigo: "promesa_prohibida" con AVISO, motivos y palabras. - b. Si el autor es de Ventas y no aceptó la versión vigente de la política: 428 politica_pendiente. - c. Si es de Ventas y tiene llamadas obligatorias vencidas hace más de 2 h, salvo cuando escribe a uno de esos mismos leads: 423 llamadas_vencidas con la lista leads. Si Firestore falla, no se bloquea.
  8. Con mediaUrl, el host tiene que ser el de Firebase Storage. Si no: 400 "mediaUrl no permitida".
  9. Desvío a Messenger (esIdMeta): messengerFns.enviar(...). Escribe el o los mensajes con canal: "messenger" (un adjunto con texto son dos mensajes, mid y midTexto), aplica tomarLeadPorTurno y actualiza last*. Si el error contiene "outside", "window" o "24", responde que la ventana de 24 h de Messenger está cerrada. Messenger no pasa por la guarda de opt-out ni tiene plantillas, reacciones, ubicación ni contactos.
  10. Plantilla y opt-out: si type == template y la conversación tiene optOut === true: 409 "El cliente pidió no recibir más mensajes". El opt-out solo frena plantillas (que son lo que Meta cuenta como spam). El texto libre dentro de la ventana no se frena aquí.

El servidor no verifica la ventana de 24 h para texto libre. Meta rechaza con 131047 y ese error se traduce.

Armado del payload según el tipo

  • reaction: {message_id, emoji}.
  • template: {name, language: {code: language || "es"}, components}.
  • audio (siempre transcodifica): 1. Descarga de Storage. 2. ffmpeg a OGG/Opus: 24 kbps, mono, 48 kHz, application voip, vbr, 30 s. 3. Si el resultado pesa menos de 100 bytes, se trata como corrupto: 502. 4. Sube a /{phoneId}/media con multipart audio/ogg; codecs=opus. 5. Si no vuelve id, plan B: AAC/M4A a 64 kbps, 44,1 kHz, subido como audio/mp4. Pierde el formato de nota de voz, pero mejor un audio básico que nada. 6. Espera 3 s (propagación en el CDN de Meta). 7. Payload {id, voice: true}, sin voice si fue M4A. voice:true es lo que da nota de voz nativa (con descarga automática y forma de onda). Sin eso, iOS no reproduce OGG como archivo genérico.
  • video: si la extensión no es mp4, 3gp o 3gpp, transcodifica (libx264, preset fast, crf 28, AAC 96k, +faststart, 90 s), sube y espera 3 s. Si ya es mp4, va por link.
  • image: siempre intenta subir a Meta y mandar por id. Mandar por link falla con "Downloading media from weblink failed with http code 500" en fotos grandes o con hipos de Storage. Convierte webp, bmp, tif y svg, o cualquier imagen mayor de 4,5 MB (WhatsApp acepta hasta 5 MB), a JPEG de ancho máximo 1600 px con q:v 3. Espera 1 s. Si algo falla, cae a link.
  • document: igual que imagen. Si el MIME viene vacío u octet-stream y el nombre tiene ".pdf", usa application/pdf. Lleva filename.
  • location y contacts: payload directo (el teléfono del contacto con tipo CELL).
  • Cualquier otro tipo: text: {body}.
  • Si viene replyToWamid y no es reacción: context.message_id.

Envío a Graph /{phoneId}/messages

  • Error: si era audio con voice:true, reintenta una vez sin voice. Si no, 502 con errorMetaEnvio:
Código Meta Mensaje al vendedor codigo
131047 La ventana de 24 h está cerrada: mandá una plantilla ventana_cerrada
131026 Este número no tiene WhatsApp sin_whatsapp
130429 Meta está limitando los envíos limite_meta
131056 Demasiados mensajes a este número demasiados_a_este_numero
132001 Esta plantilla no está aprobada en Meta plantilla_no_existe
otro Error enviando mensaje error_meta
  • Una reacción que sale bien responde {ok, wamid} sin escribir nada en Firestore.

Texto que queda guardado (body)

  • template: templateBody (el texto real con las variables resueltas, que manda la pantalla) o [Plantilla: nombre].
  • location: 📍 nombre.
  • contacts: 👤 nombre.
  • document: 📄 fileName.
  • otros: text, caption o [tipo].

Persistencia en este orden

  1. El mensaje se crea de inmediato con status: "sent", sentBy, templateName, templateUrlParam, etc. El estado failed o delivered del webhook puede llegar antes de que terminen los pasos siguientes. Si no encuentra el wamid lo descarta, y el mensaje quedaba con el tick gris para siempre y sin mostrar el error.
  2. tomarLeadPorTurno(db, phone, autor) (ver 5). Si falla, solo queda un warning.
  3. arrancarLlamadaGoogle(db, phone, autor, now) (ver 6).
  4. Conversación: lastMessage, lastMessageAt, lastMessageDirection: "outbound", updatedAt. Va después de la reasignación, que necesita ver inbound.
  5. Respuesta: {ok, wamid, messageId, tomado?, tomadoDe?}.

Reasignación por turno al contestar (tomarLeadPorTurno)

Regla: si el cliente fue el último en escribir y el dueño del lead no está en turno, el vendedor de Ventas en turno que le contesta se queda con el lead, sin aprobación. Gana el primero que envía. El lead no vuelve solo al dueño anterior: lo recupera contestando, como cualquiera.

No se mueve cuando:

  • el autor está vacío o es *.bot;
  • la conversación no existe, no tiene dueño, el dueño es el mismo autor o el dueño es *.bot;
  • lastMessageDirection !== "inbound";
  • es cliente (esCliente, esClienteManual o totalFacturado > 0);
  • el canal es empleo o proveedor;
  • el autor no es de Ventas (empleados.departamento contiene "venta" y no está inactivo; se cachea 30 min por username);
  • el autor no está en turno, o el dueño sí lo está.

Turnos (wa-nivelacion.js): roles_ventas/{lunes CR} define temprano, nocturno, domingo y sábado. Quien no tiene rol es diurno.

Rol Horario Días
Diurno 8 a 17 h (sábado 8 a 15) lunes a sábado
Temprano 8 a 16 h (sábado 8 a 15). Trabaja desde las 7, pero recibe leads desde las 8 lunes a sábado
Nocturno 12 a 22 h lunes a viernes
Domingo 8 a 17 h lunes a viernes y domingo
Sábado tarde 8 a 17 h (sábado 14 a 21) lunes a sábado

Entre las 12 y las 17 h están todos en turno, así que en esa franja no hay traspasos.

Transacción: relee y verifica que assignedTo siga siendo el mismo y que el último siga siendo inbound. Escribe assignedTo: autor, fueraDeClientes: true, reasignadoTurno {de, a, at, rolDe, rolA}, embudoVendedor: "" (salvo que esté en una columna final) y recalcula boardCol. Crea un registro en reasignaciones_turno. No toca assignedAt ni el saldo del reparto.

Llamada obligatoria a leads nuevos (reloj de 3 h y bloqueo)

Base del dato: cerca del 62 al 65 % de las ventas de Google, Directo y Sitio web cierra en las 3 h que siguen a la primera respuesta. Meta anda en 43 % y no entra en la regla.

arrancarLlamadaGoogle, dentro de whatsappSend

Arranca el reloj solo si se cumple todo esto:

  • el autor no está vacío, no es *.bot y no es sistema.web;
  • la conversación existe y source es google_ads, directo o sitio_web (YouTube no entra);
  • clienteAlEntrar !== true;
  • todavía no hay llamGVence;
  • sourceAt ≥ 17-oct-2026 00:00 hora de Costa Rica;
  • el autor es de Ventas.

En transacción (si ya hay llamGVence, no hace nada) escribe llamGInicio = now, llamGVence = now + 3 h y llamGResp = autor. Los campos llevan el prefijo "G" por historia: la regla empezó solo con Google y Directo y Sitio web se sumaron el mismo día. Las reglas de Firestore reservan estos campos al servidor o a gerencia, y llamGHecha y llamGExento solo los puede escribir quien firma con su propio username.

Compra (llamGCompraOnCita, trigger sobre citas)

Marca la compra cuando se cumple todo esto:

  • la cita tiene monto > 0 y no está en Ruta 20 (Ruta 20 = cancelada);
  • la conversación tiene llamGVence y todavía no tiene llamGCompro;
  • la cita se creó como mucho 1 h antes de sourceAt (si es más vieja, es de antes de que el cliente escribiera);
  • la versión anterior de la cita no calificaba ya igual (con monto, sin Ruta 20 y el mismo teléfono); si calificaba, esa escritura ya marcó la compra.

Si todo calza, estampa llamGCompro y llamGComproAt.

Cierre de la llamada

Se cierra de tres formas:

  • desde el formulario del embudo, con llamGHecha y llamGResultado. Los resultados son compro, le_interesa, no_contesto, no_interesa y otro_momento. Pide pantallazo obligatorio, minutos y segundos y nota opcional;
  • con "No es cliente": llamGExento más una justificación de 15 caracteres o más;
  • con la llamada de WhatsApp documentada sola (sección 2.6).

El responsable es llamAsignada || assignedTo. Basurero y Empleo quedan exentos.

Bloqueo en el servidor (llamGBloqueo)

  • llamGPendientes: una sola consulta para todos, cacheada 60 s. Busca llamGVence entre max(30-oct-2026 00:00 CR, ahora − 7 días) y ahora − 2 h.
  • Excluye las que tienen llamGHecha, llamGExento o llamGCompro, las de clientes, las del basurero y las de empleo.
  • Si el vendedor aparece en la lista, la vuelve a leer en fresco (para no frenar a quien acaba de registrar).
  • Si debe alguna y el destino no es uno de sus leads pendientes, whatsappSend responde 423.
  • En el embudo la presión se suavizó: el aviso sale como mucho una vez por hora y siempre se puede cerrar. El bloqueo de 2 h del servidor sigue activo.

Política de promesas (functions/politica-promesas.js)

  • Una regla, dos copias idénticas. La de public/js/politica-promesas.js avisa mientras se escribe. La de functions/ es la que decide. Se edita la de public y se copia encima con cp, sin tocarla a mano.
  • VERSION (hoy 2026-10-18). Cambiarla obliga a todos los vendedores a aceptar de nuevo en politica_promesas/{username} (campo version). La aceptación se cachea 10 min. Si Firestore falla, se considera aceptada (no se frena la venta; el texto igual se revisa).
  • APROBADAS: cuatro respuestas aprobadas (¿tienen garantía?, una sola visita, dos aplicaciones, por qué nosotros). Solo pasan si el texto es idéntico.
  • Normalización:
  • sin tildes y en minúsculas;
  • el emoji 💯 se traduce a "100 %";
  • letras sueltas separadas por espacio, punto, guion, asterisco o coma se juntan ("g a r a n t i a");
  • números que hacen de letras dentro de una palabra se cambian (0→o, 1→i, 3→e, 4→a, @→a, 5→s, 7→t, $→s);
  • letras repetidas se reducen a una ("erradicar" pasa a "eradicar");
  • todo lo demás se pasa a espacios.
  • Se ignoran los enlaces propios del sitio (la página de garantía y el formulario del caso) antes de revisar.
  • Detección: 1. Subcadenas: garant, garnti, grantia, grantiz, guarant, warant (motivo garantía); eradic, eradik, eraddic (motivo erradicación). También se buscan sobre el texto sin espacios, para atrapar "ga-ran-tía". 2. Distancia de Levenshtein contra la familia de palabras: 1 letra para palabras de 6 o 7 letras, 2 para las de 8 a 16. Se excluyen falsos positivos como predicar y radicar. 3. Frases: eliminación total o por completo; eliminar "todas las cucarachas" (y otras plagas); exterminio total; "acabar con... para siempre"; "desaparecen por completo"; "control, resultado o eficacia total, asegurado o garantizado"; "100 %" o "cien por ciento", salvo cuando sigue natural, seguro para, biodegradable u orgánico; "para siempre" o "de por vida"; "solución definitiva"; "nunca más vuelven"; "seguro que se van"; "libre de plagas" o "cero cucarachas"; "le aseguramos que no vuelven"; "resultados asegurados".
  • Salida: {ok, motivos[], palabras[]}.
  • Dónde se aplica: whatsappSend (422), crearPlantillaWA (texto, botones y ejemplos del cuerpo y del encabezado) y wa-envio.enviar (el bot). El audio no se revisa.
  • Los intentos bloqueados van a promesas_bloqueadas: usuario, autor, phone, origen, tipo, plantilla, motivos, palabras, texto (2000 caracteres) y at.

WhatsApp oficial: endpoints menores (leído, borrado, asistente, evaluación, atribución)

whatsappMarkRead

  • Body {phone, wamid}.
  • Messenger: solo pone unreadCount: 0.
  • Con wamid: POST {status: "read", message_id} a Graph (el cliente ve el doble check azul).
  • Con phone: unreadCount: 0.

whatsappDeleteMessage

  • Es un borrado lógico y solo en el CRM. La Cloud API no permite borrar un mensaje ya enviado: el intento anterior con status:"deleted" siempre devolvía el error 100.
  • Pide wamid y, si viene, messageDocId (validado con ^[A-Za-z0-9_-]{1,64}$).
  • Solo pueden borrar el autor (sentBy == username) o gerencia: un mensaje con una promesa es evidencia.
  • Escribe deleted, deletedAt y deletedBy, más whatsapp_activity con action: "mensaje_eliminado". Responde metaDeleted: false.

whatsappAssistant

  • Body {messages[], contactName, vendedorName}. Usa solo el primer nombre del vendedor.
  • Arma la conversación como líneas "Vendedor:" y "Cliente:". Modelo liviano, 500 tokens.
  • El prompt de sistema trae formato, saludo, flujo de venta, precios de referencia, objeciones, seguimientos y prohibiciones. Incluye la política de promesas (nunca garantía; dos aplicaciones si no fumigó en 6 meses) y la prohibición de pedir ubicación GPS.
  • Si la IA está sin saldo: 503 ia_sin_saldo y aviso a gerencia (iaSaldo).
  • Devuelve {suggestion}.

whatsappEvaluate

  • Body {employeeName, conversations[]}.
  • Toma como mucho 15 conversaciones y los últimos 30 mensajes de cada una. El nombre se sanea contra inyección de prompt.
  • Siete criterios. Devuelve un JSON con score (1 a 10), fortalezas, oportunidades, erroresOrtografia, preguntasSinResponder, ejemplosMejora y resumen. Modelo liviano, 1500 tokens.
  • Si el JSON no se puede leer, devuelve un objeto vacío con el texto en resumen.

whatsappAtribucion

  • Body {messages[], vendedores[≥2]}. Usa los últimos 60 mensajes saneados.
  • Pesos: agenda y cierre 30 %, persuasión y paquetes 25 %, calidad de información 20 %, seguimiento 15 %, tiempo de respuesta 10 %.
  • Niveles permitidos: 100/0, 75/25, 50/50 y los inversos. El saludo genérico vale 0.
  • La sugerencia se descarta (422 atribucion_invalida) si los dos usernames no están en la lista, son iguales o los porcentajes no suman 100.

Llamadas salientes y entrantes

whatsappCallAction

  • Body {action, callId, sdp, sdpType, to}.
  • connect (llamada saliente): 1. Lee el permiso local (waPermisoLlamada.leer). Si dice que no, sincroniza con Meta. Si sigue sin permiso: 403 sin_permiso_llamada. 2. Tope de 20 llamadas salientes por usuario por día en _rate_limits/call_{uid}_{día CR}. Si se pasa: 429 tope_llamadas. La cuota de Meta es de 100 por 24 h para todo el número. 3. POST /{phoneId}/calls con {type: voice, action: connect, session: {sdp, sdp_type: offer}}. 4. Si sale bien, crea whatsapp_calls/{id} con direction: outbound, status: calling, sdpOffer y calledBy.
  • Otras acciones (pre_accept, accept, terminate): {call_id, action, session con sdp_type answer}.
  • El error 138003 se traduce a "ese número todavía tiene una llamada abierta".
  • Usa WebRTC con STUN públicos.
  • Entrantes: escucha whatsapp_calls con status == "ringing", ordenadas por timestamp desc, limit(5) (con limit(1) la segunda llamada nunca aparecía). Solo suena para empleados de Ventas activos. Una llamada sirve si es inbound, no fue silenciada localmente y tiene menos de 45 s.
  • Si el que llama es lead del vendedor que mira la pantalla, el aviso pasa a modo urgente con otro timbre. Tampoco reemplaza el popup visible por una llamada más nueva.
  • Aceptar: 1. Confirma que siga en ringing. 2. getUserMedia, aplica la oferta y crea la respuesta cambiando a=setup:actpass por active. 3. Junta los ICE (máximo 5 s) y vuelve a confirmar que siga sonando. 4. Manda pre_accept y después accept, y escribe status: active, answeredBy y answeredAt.
  • Si hay error antes de que pre_accept saliera bien, no manda terminate, porque cortaría la llamada que otro compañero ya atendió.
  • Rechazar es local: solo deja de sonar en esa pantalla. Cortar para todos pide confirmación, manda terminate y deja status: rejected y rejectedBy.
  • Saliente: 1. Arma la oferta, junta ICE (máximo 5 s) y manda connect. 2. Escucha el documento hasta que llegue sdpAnswer, lo aplica como respuesta y arranca el cronómetro.
  • Colgar sin startTime queda como no_answer, no como una "contestada de 0:00".
  • Si el estado de la conexión WebRTC pasa a failed o closed (o queda en disconnected más de 6 s), corta.
  • Al terminar avisa al embudo (c2LlamWaTerminada) para abrir el formulario del resultado.

Permiso de llamada (functions/wa-permiso-llamada.js y endpoints)

  • Colección whatsapp_call_permissions/{tel} con status (granted, pending, denied, expired o none), isPermanent, expiresAt, requests[] (ms, podados a 7 días), lastRequestAt, lastRequestBy, lastRequestTemplate, lastReplyAt, lastReplySource, lastInboundCallAt, statusMeta, metaPuedeLlamar, metaPuedePedir y syncAt.
  • Estados reales de Meta: devuelve temporary, permanent y no_permission, que no figuran en la documentación. Se normalizan: los dos primeros a granted, el último a none.
  • Vigencia: el permanente no vence. El temporal se calcula contra el reloj con expiresAt, y si Meta no manda fecha son 7 días. Una llamada entrante abre 24 h para devolverla.
  • Límites de pedidos: 1 cada 24 h y 2 cada 7 días. Si el cliente rechazó (denied), no se vuelve a pedir.
  • sincronizar: GET /{phoneId}/call_permissions?user_wa_id=. start_call.can_perform_action manda sobre el status (da autorizado_meta o revocado_meta). Si local dice pending y Meta dice none, se conserva pending: Meta no sabe que le preguntamos y todavía no contesta.
  • guardarRespuesta: un sí limpia requests[]. Un permanente borra expiresAt.
  • whatsappCallPermission: si la copia local dice que se puede llamar y no se pidió sync, responde local. Si no, consulta a Meta, y si Meta falla responde con lo local más avisoSync.
  • whatsappPedirPermisoLlamada: 1. Messenger: 400. optOut: 409. Ya puede llamar: 409. Límite de pedidos: 429 con motivo y esperaHasta. 2. Ventana abierta (lastInboundAt de menos de 24 h): mensaje interactive de tipo call_permission_request (gratis, sin plantilla). 3. Ventana cerrada: hace falta una plantilla. Si no viene, responde 400 necesitaPlantilla. 4. Error 138007: Meta no acepta otro pedido todavía. 5. Registra el pedido, guarda el mensaje con type: call_permission_request y body = cuerpoReal (el texto exacto que leyó el cliente), y pone lastMessage "Permiso de llamada solicitado".
  • Plantillas de permiso: llevan el componente de primer nivel {"type":"CALL_PERMISSION_REQUEST"}, sin botones. Un botón VOICE_CALL no sirve para esto: marca directo al tocarlo (ya le sonó el teléfono a un cliente). Meta bloquea reutilizar el nombre de una plantilla borrada durante unas 4 semanas. Sin el parámetro {{1}}, Meta devuelve 132000. El 132001 significa que todavía está en revisión.
  • Frontend (public/wa-permiso-llamada.js):
  • Envuelve window.makeCall: sin permiso abre el modal. Si Meta rebota la llamada, sincroniza y ofrece pedir permiso.
  • Seis motivos, cada uno con texto para dentro de la ventana y plantilla para fuera.
  • Primero intenta dentro de la ventana. Si recibe necesitaPlantilla, reintenta con la plantilla usando el primer nombre (limpio de emojis; si el nombre es solo un teléfono, usa "buenas").
  • Al terminar llama a evReasignarPorRecontacto (regla de lead frío).

Lead frío al raspar (reasignarLeadFrio y backfill)

Regla: si un vendedor manda una plantilla de recontacto (raspa) o pide permiso de llamada a un teléfono que no es cliente, sin ventas y con más de 3 días sin ningún mensaje, el lead pasa a quien lo revive. El navegador no puede hacer esto porque las reglas de Firestore no permiten tocar el assignedTo ajeno. En el embudo, si el lead no tiene dueño lo toma el navegador; si es de otro, llama a esta función.

Flujo:

  1. Messenger: 400. Sin teléfono: 400. Solo Ventas o gerencia (si no, 403).
  2. Si la conversación no existe o ya es del solicitante: {reasignado: false}.
  3. Si esCliente o esClienteManual: no reasigna, con mensaje.
  4. Frío:
ms = últimos 25 mensajes del teléfono, del más nuevo al más viejo
recorrer ms, saltando los salientes del propio solicitante de los últimos 10 min
ultimoMs = timestamp del primero que no se saltó
si se saltaron todos: ultimoMs = lastInboundAt
diasFrio = floor((ahora - ultimoMs) / 1 día), o 9999 si no hay ninguno
si ultimoMs y diasFrio < 3: no reasignar, explicando cuánto hace del último movimiento
  1. Sin ventas: busca en citas por telefono y por telefono_normalizado, con las variantes (con 506, sin 506). Si encuentra alguna, no reasigna.
  2. Transacción: relee y verifica que el dueño no cambió y que no es cliente. Escribe assignedTo, embudoVendedor: "", esClienteManual: false, fueraDeClientes: true, updatedAt y boardCol.
  3. Registra whatsapp_activity con action: "asignado".

Lecciones:

  • Cualquier regla que se evalúe después de un envío tiene que ignorar ese mismo envío. Entre el 3 y el 9 de octubre la función medía con lastMessageAt, que el envío acababa de mover a "ahora", y no reasignó a nadie.
  • Después, con limit(5), una campaña de 5 plantillas propias dejaba ultimoMs = 0 y la regla tomaba un lead ajeno que había escrito ese mismo día. Por eso ahora se miran 25 mensajes y, si todos son propios, manda lastInboundAt.

backfillLeadFrio (solo gerencia, máximo 300 s):

  • Body {dias (1 a 14, por defecto 3), aplicar, forzar: [{phone, a}]}. Sin aplicar solo cuenta.
  • Toma la primera plantilla por (teléfono, vendedor) en el período.
  • Salta: lead sin dueño o que ya era del raspador; dueño especial (reclutamiento o bot); cliente; lead no frío (medido antes de la plantilla, saltando los propios de los 10 minutos previos); lead reasignado a mano después del raspado; lead con ventas.
  • forzar asigna directo, sin evaluar la regla.
  • Devuelve los conteos por motivo.

Primer asignado (estamparPrimerAsignado, trigger sobre whatsapp_conversations)

  • Si ya existe primerAsignado, no hace nada.
  • Si no, el primer asignado es el dueño anterior cuando era humano, o el nuevo. Los dueños *.bot no cuentan: se espera a la primera persona.
  • primerAsignadoAt es el assignedAt solo si lo cambió esta misma escritura (las asignaciones a mano no lo tocan y puede venir viejo). Si no, la hora del servidor.
  • En transacción: escribe primerAsignado en minúsculas y primerAsignadoFuente: "al_asignar". El reparto por nivelación lo escribe por su cuenta en la misma transacción, con fuente reparto_nivelacion.
  • Uso: los leads de la semana se cuentan para quien los recibió primero. Un raspado o una cesión no se lo quitan.

Plantillas de WhatsApp

public/wa-templates.js

  • Es la fuente única de plantillas: DEFAULT_TEMPLATES tiene 437 definiciones (309 de marketing y 128 de utilidad; 39 con imagen en el encabezado; 24 con botón URL).
  • Cada una trae id, name, category, language, headerType, headerImageUrl, headerText, bodyText (con {{n}}), footerText, buttons[], buttonType, buttonUrl, paramLabels, urlParamLabels y keywords.
  • TPL_GROUPS las agrupa por propósito: primer contacto, cotización, plagas, objeciones, preguntas frecuentes, seguimiento, raspa/retomar, reactivación, confirmación, recordatorio, coordinación, cancelación, cobro, post-servicio, garantía, reclutamiento, recuperador, etc. Las que no tienen grupo caen en "otros" (getTplGroup).
  • seedTemplates(db) sincroniza con whatsapp_templates: crea, actualiza solo si cambió algún campo y borra las huérfanas.
  • El opt-out se ofrece como botón de respuesta rápida ("No deseo más mensajes"). El bot lo detecta (botón con "no deseo", o regex más confirmación de modelo liviano) y escribe optOut: true y optOutAt.

crearPlantillaWA

  • Borrar: {borrar: nombre}, solo gerencia. El nombre tiene que calzar con ^[a-z0-9_]{1,512}$. Hace un DELETE en la WABA por nombre.
  • Crear: pide el permiso whatsapp. Pasa la política de promesas sobre textos, botones y ejemplos. Hace POST a la WABA (fija) sin el wabaId en el cuerpo y devuelve {ok, data}.
  • Reglas de Meta que ya rebotaron: los botones de respuesta rápida no aceptan emojis; un encabezado de imagen exige example.header_handle, que se obtiene por la Resumable Upload API de la app, porque Meta no acepta una URL como muestra.

estadisticasPlantillas

  • Sin ?recalcular devuelve la caché plantillas_stats/resumen. Recalcular cuesta unas 65 mil lecturas y es solo para gerencia.
  • Recalcular: 1. Lee los mensajes ordenados por templateName (solo los campos usados) y todos los entrantes. 2. "Respondió en 24 h" busca con búsqueda binaria el primer entrante estrictamente posterior al envío. 3. Cuenta por plantilla: envíos, entregados (read cuenta también como entregado), leídos, fallidos, respondidos, primer y último uso, y los 3 usuarios que más la mandan. 4. Une el resultado con el catálogo vivo de Meta (estado, categoría, quality_score) paginando de a 200. 5. Marca las "nunca usadas" y las "huérfanas" (usadas pero ya no están en Meta). 6. enviosPorFecha se calcula por día de Costa Rica (en UTC, lo raspado de 6 pm a medianoche caía al día siguiente) y se recorta a 180 días, para no pasar de 1 MiB por documento.

Hallazgo de negocio (octubre 2026): lo que decide si una plantilla rinde es cuántos días lleva callado el lead, no el número de plantilla. Con 2 o 3 días de silencio todas suman. Con 8 o más días, la primera suma un poco, la segunda no se distingue de cero y la tercera no vendió nada en 111 envíos. El costo por plantilla no es el problema.

Calidad de escritura (evaluarEscrituraVendedores)

  • Permiso reportes_gerenciales. Toma dias entre 1 y 30 (por defecto 7).
  • Candado: si el reporte de hoy (reportes_escritura/{día CR}) tiene menos de 12 h, lo devuelve sin volver a pagar el modelo.
  • Qué se evalúa: solo texto. Excluye plantillas (incluido lo que trae templateName), mensajes borrados, fallidos, de menos de 15 caracteres o menos de 3 palabras, y los que son solo un enlace.
  • Un guion repetido se cuenta una sola vez (se normalizan URLs, números y espacios) y se anota cuántas veces se usó.
  • El vendedor necesita al menos 5 textos únicos. Se envían como máximo 140 mensajes por vendedor (los más repetidos primero), recortados a 900 caracteres.
  • Modelo grande con razonamiento adaptativo: hay que filtrar los bloques de tipo text, no tomar el primero. Tres reintentos con espera creciente y tandas de 3 vendedores en paralelo.
  • Salida: notas de ortografía, puntuación, mayúsculas, gramática, profesionalismo y concisión, más nota global, resumen, fortalezas, a mejorar y hasta 12 hallazgos.
  • El voseo y el trato tico respetuoso no cuentan como errores.

Tiempos de respuesta (functions/tiempos-respuesta.js)

  • Corre cada 30 min, solo entre las 6:00 y las 22:59 de Costa Rica. Recalcula hoy y ayer (las respuestas a mensajes de ayer pueden llegar hoy).
  • Qué mide: por cada entrante que abre una ráfaga (el mensaje anterior no era entrante), el tiempo hasta la siguiente respuesta humana.
  • Respuesta humana: saliente, que no sea del bot (bot != true y el autor no termina en .bot), que no sea type: call y que no la haya firmado el sistema web.
  • Solo cuentan los leads con dueño de Ventas, y solo si el cliente escribió en un minuto en que el dueño estaba activo en la app (presence_days/{user}_{día}.slots[minuto] con clics o teclas mayores a 0).
  • Sin respuesta en 24 h queda como null (fuera de la mediana).
  • Guarda en tiempos_respuesta/{día} un datos como texto JSON de filas [username, canal, minutos]. Va como texto para que Firestore no indexe cada elemento.

Atributos de CRM (crm-atributos.js y actualizarAtributosCrmScheduled)

  • Horario: todos los días a las 4:45 hora de Costa Rica, con 540 s y 1 GiB. A las 4:30 corre la reconciliación de clientes.
  • Marca de avance: crm_meta/atributos.ultimoTimestamp es el timestamp del último mensaje procesado, no la hora de la corrida. Antes había un solape de 2 h que se volvía a sumar todas las noches. La consulta usa timestamp > estricto.
  • Backfill sin función nueva: gerencia escribe backfillPendiente: true y backfillDesde, y la corrida siguiente recalcula ese tramo reemplazando los acumulados.
  • Lotes: de unos 400 teléfonos. Nunca se corta entre dos mensajes con el mismo timestamp (corte forzado a los 480). Cada lote se confirma en un solo batch junto con la marca, así una corrida que muere a mitad no vuelve a sumar lo ya escrito.
  • Cómo se fusiona: los contadores se suman, las banderas se combinan con OR, las listas se unen, la fecha del primero se conserva y la del último se toma la mayor.
  • Los rastros de llamada (type: call) no cuentan como mensajes: van a total_llamadas y llamadas_perdidas.
  • Léxico: plagas, tipo de propiedad, intención (precio, agendar, urgencia, garantía, queja), mascotas y niños, empleo. Tiene que coincidir con el script de backfill manual.
  • Campos derivados: nunca_respondio, conversacion_profunda (más de 10 entrantes), nos_dejo_en_visto (último mensaje nuestro hace más de 7 días), ventana_abierta, plantillas_distintas (usa __tpls acumulado, con el valor histórico como piso), horario_preferido, y los que se leen de la conversación (embudo, vendedor, archivado, etiquetas).
  • Después de los atributos corren refrescarDerivados (días desde el último mensaje, antigüedad, dejado en visto) y, con el tiempo que sobre de un tope de 480 s, el barrido de media.

public/wa-chat-shared.js

  • detectMediaType, formatBytes, parseLatLng (enlaces de Maps con @lat,lng, !3d!4d, ?q= o "lat, lng"), geocodeAddress (geocodificador público de OpenStreetMap, Costa Rica por defecto) e isForwardableMediaUrl (rechaza la media de Meta, que no se puede re-subir).
  • runSendQueue: envío secuencial. Si hace falta comprime el video, sube el archivo a Storage en whatsapp_media/{tel}/{ts}_{nombre} y llama a whatsappSend. Solo el primer elemento lleva replyToWamid. Espera 350 ms entre envíos y devuelve {sent, failed[]}.

WhatsApp oficial: casos borde y lecciones

  1. Responder 200 después de guardar y 500 ante cualquier error. Todo el webhook tiene que ser idempotente (id = wamid, contador solo la primera vez, last* solo avanza).
  2. Los estados de entrega pueden llegar antes que el documento del mensaje: se crea el mensaje apenas responde Meta.
  3. El canal es MECE y congelado: cada conversación tiene exactamente un canal, decidido en el primer toque. Los aspirantes a empleo se separan, también por campaña.
  4. Reasignación por turno: se evalúa antes de escribir outbound y en transacción.
  5. Las reglas posteriores a un envío ignoran ese envío (lección del lead frío).
  6. La política de promesas la decide el servidor. El navegador solo avisa. Vale también para gerencia y para las plantillas nuevas.
  7. Opt-out: frena las plantillas y los pedidos de permiso, no el texto libre. Messenger no lo revisa.
  8. Ventana de 24 h: 72 h si el cliente entró por un anuncio. El servidor no la valida para texto: se traduce el 131047.
  9. Media saliente: subir a Meta y mandar por id, con plan B por enlace. Audio como OGG/Opus con voice:true, con plan B M4A sin voice.
  10. Media entrante: trigger con reintento, tope de 6 h, barrido nocturno con 5 intentos y ruta determinista en Storage.
  11. Llamadas: distinguir saliente de entrante por el SDP. Rechazar es local; cortar para todos va aparte. Nunca mandar terminate antes de que el pre_accept propio haya salido bien.
  12. Permiso de llamada: Meta manda sobre el estado local. El pending es nuestro y hay que protegerlo del sync. Nunca usar el botón VOICE_CALL como pedido de permiso.
  13. Autor: siempre del token. Solo gerencia firma por otra persona.

WhatsApp oficial: protocolo de pruebas

  • Toda prueba de envío va entre dos números internos fijos de gerencia, nunca a clientes.
  • Antes de mandar, verificar el encabezado del chat abierto y abortar si no es el número esperado o si es un grupo. Un mensaje enviado no se deshace: ya cayó una prueba en un grupo real del equipo.
  • Las reglas de Firestore se prueban contra el emulador: que compilen no significa que estén cerradas.
  • El login desde localhost está bloqueado, así que se publica primero y se prueba en vivo.

CRM: pantallas vigentes

Pantalla Estado Permiso
embudo_vendedor.html + embudo-vendedor.js (unas 8.400 líneas) Es el CRM vigente de los vendedores, versión 7.23 embudo_vendedor
embudo.html + crm2.js Tablero kanban por columnas. Se reactivó a pedido de gerencia y está en el menú whatsapp
crm2.html Redirige a embudo.html y conserva ?view= whatsapp
crm.html Retirado el 20-ago-2026. Redirige a crm-retirado.html?de=crm. La página entera se reemplazó para que whatsapp-crm.js no cargue whatsapp
whatsapp.html Redirige a embudo_vendedor.html n/a
leads.html Leads de formularios en el proyecto Firebase secundario del sitio (colección contactos) leads
leads_b2b.html + leads_b2b.js Directorio B2B y mini-CRM leads_b2b
whatsapp-analitica.html Analítica de chats y evaluación con IA whatsapp
desempeno_crm.html Velocidad y conversión por vendedor reportes_gerenciales
leads_por_hora.html Leads nuevos, con días en filas y horas en columnas embudo_vendedor, whatsapp o reportes_gerenciales
reporte_horas_leads.html Mapa de calor de 7 días por 24 horas reportes_gerenciales
bot_ventas.html Administración de los vendedores virtuales bot_ventas

Lección: cualquier enlace que lleve a un chat tiene que apuntar a embudo_vendedor.html?tel= (o ?phone=). El botón "Confirmar CRM" de la agenda estuvo roto porque abría crm.html?phone=...&tpl=....

CRM: modelo de datos común

whatsapp_conversations/{id}

El id es el teléfono normalizado 506XXXXXXXX, o fb_<psid> en Messenger.

Grupo Campos
Identidad phone, name (lo edita el vendedor), whatsappName (perfil), tags[], nota, createdBy, createdAt, updatedAt
Último movimiento lastMessage (100 caracteres), lastMessageAt, lastMessageDirection (inbound u outbound), lastInboundAt, unreadCount, unreadPrev, archived
Ventana de Meta windowExpires (24 h, o 72 h si vino de un anuncio con referral), windowOrigin, sinVentana (lead web que nunca escribió)
Origen, congelado en el primer toque source (ad, google_ads, youtube, tiktok, bing, sitio_web, directo, messenger, empleo, proveedor, nuevo_contacto_embudo, saliente), subSource, sourceAt, clienteAlEntrar, metaPlataforma (facebook, instagram o whatsapp), adCampaignName, adHeadline, adBody, googleAdsRef (G-xxxx), gclid, wbraid, directoTipo
Web webRef, webRefUltimo, webSubcanal, webLanding, webReferrer, webTipoPagina, webPlaga, webCiudad, utmSource, utmMedium, utmCampaign, utmContent, fbclid, msclkid, webDevice, webLeadAt, webLeadOrigen, webContexto{}, webSeguimientoPendiente, webAlertaRapidaAt, webSeguimientoAt, webPlantilla{enviada,error,at}
Propiedad assignedTo, assignedAt (solo lo escribe el reparto), primerAsignado, primerAsignadoAt, primerAsignadoFuente (los congela el trigger estamparPrimerAsignado), fueraDeReparto, reasignadoTurno{de,a,at,rolDe,rolA}
Columna embudoVendedor (clave de embudo final o vacío), boardCol (columna desnormalizada), esCliente y totalFacturado (los escribe el servidor), esClienteManual, fueraDeClientes, recuperadorEnviado
Raspa raspadoAt, raspadoPor, raspadoPlantilla, raspadoVenta
Seguimiento followUpAt, followUpNote
Llamadas de WhatsApp llamadaPendiente, lastCallAt
Llamada documentada llamDocHecha, llamDocAt, llamDocPor, llamDocN, llamAsignada, llamAsignadaAt, llamAsignadaPor
Llamada obligatoria llamGInicio, llamGVence, llamGResp (solo servidor), llamGHecha, llamGAt, llamGPor, llamGSeg, llamGResultado, llamGFuente, llamGCompro, llamGComproAt (servidor), llamGExento, llamGExentoMotivo, llamGExentoPor, llamGExentoAt
Messenger canal, psid, fbPageId, fbThreadId

boardCol

Es la columna precalculada para poder contar y paginar en el servidor. Se calcula igual en boardColumnKey (navegador) y en boardColFor (servidor):

si embudoVendedor está en FINAL_KEYS          → embudoVendedor
si (esCliente o esClienteManual) y !fueraDeClientes:
       recuperadorEnviado ? "__recuperador__" : "__clientes__"
si no hay assignedTo                          → "__sin_asignar__"
si no                                         → assignedTo

Toda escritura que toque embudoVendedor, assignedTo, esClienteManual o recuperadorEnviado pasa por conBoardCol(prev, cambios).

Hay una autorreparación, repararBoardCol. Si llega un documento con la columna desfasada, la corrige en memoria y hace updateDoc. Tiene tope de 25 escrituras por sesión y deduplica por teléfono. Existe porque otras pantallas (raspa en masa, lealtad) escriben assignedTo sin conocer la regla. La red de seguridad final es reconcileClientesScheduled, que corre a las 4:30 a. m.

Otras colecciones

  • whatsapp_messages/{wamid o id}: conversationPhone, direction, type, body, mediaUrl, timestamp, wamid, sentBy, status, contactName, bot, canal y webLead.
  • whatsapp_activity: phone, action, details, performedBy, timestamp. Las acciones son asignado, autoasignado, embudo_movido, contacto_creado, nombre_editado, recordatorio, mensaje_eliminado, empleo_asignado, cedido, reclamo y modo_reparto.
  • El historial del chat solo muestra las acciones que están en el catálogo C2_MOV_META. Una acción nueva que no se agregue ahí se guarda y nunca se ve.
  • details usa el formato "Origen" → "Destino". desempeno-crm.js lo parsea con comillas tipográficas incluidas.
  • Configuración del reparto en whatsapp_autoassign_config:
  • settings: autoAsignar, modoReparto, barajaIgualdad, eligibleEmployees, lastAssignedTo y los campos de autoría.
  • asignaciones24h: libro mayor con buckets y grupo.
  • lock: lease de 90 s.
  • override_elegibilidad: horario de 7 franjas de 24 caracteres "0/1" y lista de quiénes reciben fuera de horario.
  • Otras: whatsapp_unavailable (pausas con endTime == null), work_sessions (status == "working" = turno abierto), presence_live (latidos), whatsapp_calls, whatsapp_reclamos, llamadas_documentadas, garantias.

embudo_vendedor.html: el CRM de los vendedores

Propósito y usuarios

Es la pantalla de trabajo diario de los vendedores (departamento Ventas) y de gerencia. Nació como copia de crm2.js sin kanban. Los comentarios piden que cualquier arreglo del hilo o del composer se replique en los dos archivos.

Layout

Tiene tres columnas: Chats a la izquierda, Conversación al centro y Clientes facturados a la derecha. En móvil se alternan con un botón.

  • Barra superior (#evFiltros)
  • Grupo "Asignación": "Todos", una pastilla por vendedor de la rotación, "Otros (n)" y "Sin asignar". Es selección múltiple con check. La pastilla se pinta en verde si el vendedor recibe leads y en rojo si no; el tooltip dice el motivo.
  • Grupo "Categorías": radios de selección única. "Todas", "Sin asignar", Empleo, Cotizaciones, Rural, Garantías, Raspa, Basurero y Otro. Después de una divisoria vienen las dos bodegas, Leadsales y Recuperador 3000.
  • Cada chip lleva una burbuja con los contactos que tienen mensajes sin leer, no el total. El total real va en el tooltip.
  • Si la categoría tiene subcategorías aparece una fila extra. Empleo: Prospecto, Descartado y Sin clasificar. Raspa: Por raspar y No raspar más. Recuperador: más de 1 año, 6 a 12 meses, últimos 6 meses y 2 o más servicios. Leadsales: ya son clientes, nunca facturaron, ya pasados al CRM y no contactables.
  • Barra extra (#evFiltroExtra)
  • Selector de canal (#evFCanal) con conteo por canal.
  • "Primer asignado" (#evFPrimer), que filtra por primerAsignado.
  • Rango "Ingresó" (#evFDesde y #evFHasta) sobre sourceAt en hora de Costa Rica.
  • Atajos Hoy, Semana y Mes, y un botón para limpiar.
  • Herramientas
  • Menú de cola (#evQueueMenu): rotación, modos de reparto, Repartir y Automático.
  • "Por vencer" (#evPorVencer): ventana con menos de 12 h.
  • Buscador (#evBuscaTodo). Busca por teléfono con o sin 506, nombre, último mensaje, etiquetas, nota y vendedor, sin tildes y con varias palabras. Con un teléfono completo, Enter abre el chat; con texto, busca citas históricas.
  • Botones: buscar el teléfono directo en la base, buscar citas, reset, reclamos (solo gerencia), llamadas de WhatsApp con badge de perdidas, buscar garantías con IA, sincronizar clientes, enlaces a leads por hora, roles de Ventas y reasignar por fecha (gerencia), y tema.
  • Cabecera del chat
  • Avatar, nombre, chip de número extranjero y dueño, con el chip de ventana: horas restantes, "Solo plantillas" o "Ventana cerrada" en Messenger.
  • Botones: leído/no leído, recordatorio, movimientos, atributos, escudo de garantía, Reclamar, Ceder, llamada documentada (#evLlamDocBtn, se pone rojo si debe la llamada obligatoria), llamar por WhatsApp y llamar por teléfono.
  • Selector de dueño (#c2EmbudoSelect), pastilla de origen (#c2ThAd), botón de citas (#evCitasBtn) y menú Categoría (#c2CatRadios).
  • Composer
  • Respuesta citada, cola de adjuntos (máximo 10, video hasta 16 MB comprimido en el navegador), plantillas (#c2TplBtn), vista previa del bot (gerencia), archivo, texto y micrófono con pausa y previsualización.
  • Respuestas rápidas guardadas en localStorage (c2-quickreplies), que pueden ser texto o medios subidos a Storage.
  • Panel de atributos: nombre editable (registra nombre_editado), medio, perfil, embudo, asignado, "Buscar citas", etiquetas y nota.

Cómo se cargan los datos (y por qué cuesta)

Hay varios streams que alimentan un convMap. Cada teléfono recuerda de qué fuente vino (fuentes.hot, mios, unread, cli, cat, pag, guardia, rango, busqueda, bodega, recu) y solo se borra de memoria cuando ninguna fuente lo retiene.

Stream Consulta Tope
hot orderBy(lastMessageAt desc) 800
mios assignedTo == yo, orderBy lastMessageAt 5.000
unread unreadCount > 0, orderBy unreadCount asc, lastMessageAt desc 500, avisa si se llena
cli esCliente == true, orderBy lastMessageAt 5.500, avisa si se llena
llamG llamGVence >= máx(17-oct, hoy − 7 días) sin tope

Las categorías se piden a demanda con getDocs por boardCol: 1.000 por clave, con refresco de 2 minutos. Raspa se trae por tandas de 1.000 ordenadas por lastMessageAt asc y con cursor. El rango de fechas se trae por sourceAt, con tope de 4.000.

Los totales de cada categoría se cuentan con getCountFromServer por boardCol, cada 15 minutos.

Historia que conviene conocer. El 20-ago-2026 los topes se subieron a 10.000 y 2.000 porque los conteos de Empleo mentían. Eso costó unos 66 GB al día de transferencia. Hoy los conteos salen del servidor y las categorías se cargan cuando alguien las abre.

Si la pestaña queda oculta más de 3 minutos, se cortan todos los streams. Si el reparto automático está encendido, queda una guardia de 200 chats recientes que solo retiene los repartibles.

La presencia se lee con getDocs cada 60 s y no con onSnapshot, porque cada latido dispararía lecturas. Sin latido en 2,5 minutos, el vendedor cuenta como apagado.

Reglas de categorías

categoriaDe(c) se evalúa en este orden:

  1. Empleo, si embudoVendedor es __empleo__, __empleo_prospecto__ o __empleo_descartado__.
  2. Cotizaciones, Rural, Leadsales, Recuperador, Garantías y Basurero.
  3. __raspa_no_mas__ cae en Raspa.
  4. Si la ventana venció, Raspa.
  5. Lo demás es Otro.

"Todas" excluye las bodegas, Raspa, Empleo, Basurero y __no_compro__. Hay una excepción: lo vencido hace menos de 3 días (RASPA_RECIENTE_MS) sigue en "Todas", para que el lead del fin de semana no desaparezca el lunes. Con un rango de fechas puesto, "Todas" muestra todo.

Ventana: isWindowOpen mide 24 h desde lastInboundAt. Solo si ese campo falta usa windowExpires. Si hay sinVentana y no hay windowExpires, la ventana está cerrada.

Lección: windowExpires se infla a 72 h en los leads de anuncio y Meta igual rechaza el texto libre.

Orden de la lista de Chats: primero los no leídos, del entrante más nuevo al más viejo; después los leídos por último mensaje. crm2.js ordena los no leídos al revés, el que más espera primero.

Raspa cambia la pantalla a dos columnas: "Por raspar", del más viejo al más nuevo, y "Raspados · 7 días" a la derecha, con chip ₡ si vendió, flecha si contestó y avión si no hubo respuesta.

Chips de cada fila (convItemHtml)

  • Pastilla de canal a todo el alto de la fila, con color por canal. Facebook, Instagram, Estados de WhatsApp, Google, Bing, YouTube, TikTok, sitio web (WhatsApp, formulario o chat con IA), directo, Messenger, o "Sin canal" con signo de pregunta.
  • Iniciales del dueño sobre su color.
  • chipLlamadaGoogle (.ev-lg): reloj ámbar con h:mm, "POR LLAMAR" en rojo cuando vence, o check verde.
  • Chip de número extranjero, chips de Leadsales y Recuperador, y campana de recordatorio (roja si venció).
  • Llamada perdida (llamadaPendiente).
  • Llamada documentada, solo si el lead no tiene reloj de llamada obligatoria. Verde con check o ámbar con reloj de arena, más las iniciales de llamAsignada.
  • Chip de ventana: candado si venció, reloj si quedan menos de 12 h.
  • Contador de no leídos (hasta 99+) y hora.

Acciones y qué escribe cada una

  • Abrir un chat (c2Open)
  • Guarda el borrador del chat anterior en un Map por teléfono.
  • Cierra los modales de envío del chat anterior y cancela audio y adjuntos en el mismo instante en que cambia currentPhone.
  • Origen de la regla: el 14-set-2026 un precio salió al cliente equivocado.
  • Si el chat no está cargado, lo trae con getDoc. Actualiza ?tel= con replaceState.
  • Abrir no marca como leído. Marca como leído al responder (marcarLeidoPorEnvio): unreadCount: 0, unreadPrev, y whatsappMarkRead con el último wamid entrante de ese chat.
  • Enviar texto (c2Send)
  • Llama a POST whatsappSend {to, type:"text", text, sentBy, replyToWamid?}.
  • Respuestas especiales del servidor:
    • codigo:"ventana_cerrada": abre el selector de plantillas.
    • codigo:"llamadas_vencidas" (HTTP 423): abre el aviso forzado.
    • tomado:true: avisa "el lead pasó a vos" por reasignación de turno.
  • Si falla, el texto vuelve a su borrador y no a otro chat.
  • Plantillas
  • Catálogo estático en wa-templates.js, con grupos en TPL_GROUPS.
  • La variable {{1}} se llena con el primer nombre. La imagen de encabezado se busca primero en Storage (plantillas-headers/plantilla-<name>.png).
  • Se envía whatsappSend {type:"template", template:{name,language,components}, templateBody, templateUrlParam?}.
  • Después del envío:
    • reasignarPorPlantilla (ver abajo).
    • Si la ventana estaba cerrada o la plantilla es de un grupo de recontacto (retoma, reactivacion, leadsales, recuperador), escribe raspadoAt y raspadoPor.
    • Si la plantilla es la de recuperador, conBoardCol({recuperadorEnviado:true}).
  • Caso especial, la plantilla de formulario de caso de garantía:
    • Busca la última cita del teléfono en 6 variantes del número contra telefono_normalizado y telefono, saltando las ficticias.
    • Si esa cita ya tiene un caso en solicitud_enviada, reusa el código.
    • Si no, genera un token y crea garantias/{token} justo antes de enviar, con origen:"embudo_vendedor", y escribe citas/{id}.garantiaId. Si la creación falla, no envía.
  • Por URL: ?tel|phone=&tpl=&p1..pN&urlp1= deja la plantilla elegida y con variables. Nunca la envía sola.
  • Mover de categoría o de dueño (moverConversacion)
Destino Qué escribe
Clientes {esClienteManual: !esCliente, embudoVendedor:"", fueraDeClientes:false}
Sin asignar {assignedTo:"", embudoVendedor:"", esClienteManual:false, fueraDeClientes:false}
Otro {embudoVendedor:"", esClienteManual:false}
Raspa Prohibido: solo se cae ahí cuando vence la ventana
Por raspar Quita la marca de __raspa_no_mas__
Empleo {embudoVendedor, assignedTo:<usuario de reclutamiento>, fueraDeClientes:true}, registra empleo_asignado y descuenta al dueño anterior en el libro mayor
Embudo final {embudoVendedor:clave}
Bot Va por asignarLeadBot (solo gerencia)
Vendedor {assignedTo, embudoVendedor:"", fueraDeClientes:true}, solo gerencia salvo que sea uno mismo

Guardas antes de escribir: - puedeAsignar (en wa-asignacion.js, refleja firestore.rules). Un lead sin dueño lo toma cualquiera. Uno ajeno solo lo mueve gerencia. Lo propio se puede soltar o ceder. Lo propio se puede mandar a Empleo. - Un cliente facturado está "pegado" a Clientes. Sale solo si su ventana está abierta (volvió a escribir) o si lo mueve gerencia. - Cambiar la categoría de un lead ajeno es de gerencia, salvo Basurero (entrar o salir) y Empleo. - Después registra en whatsapp_activity y hace registrarAsignacionManual en el libro mayor, para que lo asignado a mano también cuente en el reparto. - Recordatorio: modal #evRecordModal con atajos en minutos, "mañana 9 a. m." y fecha manual en hora local. No acepta fechas pasadas. Escribe followUpAt y followUpNote. - Garantía: el escudo pasa el chat a __garantias__ o lo saca a Otro. "Buscar garantías" manda los últimos 30 mensajes entrantes de cada cliente a detectarGarantias (IA). - Nuevo contacto (#c2NewContactModal) - Si el contacto no existe, crea el documento con source:"nuevo_contacto_embudo" y assignedTo = quien lo crea, y registra contacto_creado y asignado. - Si existía sin dueño, lo toma vía moverConversacion. - En los dos casos abre plantillas en el grupo numero_oficial. - Ceder (#evCederModal): lo hace solo el dueño o gerencia. El motivo es obligatorio. Escribe {assignedTo:destino, esClienteManual:false, fueraDeClientes:true}, registra cedido con el motivo y mueve el libro mayor en los dos sentidos. El teléfono se fija al abrir el modal. - Reclamar (#evReclamoModal) - Exige una cita del teléfono con el reclamante en metadata.vendedores. - Desde el 19-oct-2026 llama a aprobarReclamoAutomatico, que verifica y reasigna en el acto. - Solo si el servidor no contesta crea whatsapp_reclamos {phone, solicitante, asignadoActual, motivo:"venta_propia", citaId, citaFecha, estado:"pendiente"}. - Gerencia aprueba o rechaza en #evReclamosModal, cerrando primero el reclamo y después reasignando. - Recontacto con plantilla (reasignarPorPlantilla) - Si el lead es mío, no hace nada. - Si no tiene dueño y el teléfono no tiene citas, me lo asigno (registrarAsignacionManual y actividad asignado). - Si tiene citas, avisa que es de quien vendió. - Si es ajeno, llama a reasignarLeadFrio: el servidor lo reasigna solo si no es cliente, no tiene citas y lleva más de 3 días sin ningún mensaje. Ignora el envío recién hecho mirando los últimos 25 mensajes y saltando los salientes del solicitante de los últimos 10 minutos. - El pedido de permiso de llamada reusa la misma función. - Citas del cliente: reusa el panel de js/agenda-search.js acoplado a la derecha y busca solo por teléfono contra los campos indexados. No consulta al cambiar de chat; queda esperando un clic.

Cotizar y agendar desde el embudo

El embudo no crea citas ni cotizaciones. Cotizar en la práctica es:

  1. Mandar el precio por chat, con respuestas rápidas o plantillas.
  2. Opcionalmente, mover el lead a la categoría Cotizaciones (embudoVendedor:"__cotizaciones__").

Agendar se hace en agenda.html. La cita lleva metadata.vendedores[] y metadata.monto. Lo que vuelve al CRM lo escribe el servidor:

  • stampClienteOnCita actualiza analitica_clientes y estampa esCliente, totalFacturado y boardCol (pasa a Clientes).
  • llamGCompraOnCita estampa llamGCompro.

El módulo de cotizaciones con fotos es otra página, sin enlace desde el embudo.

Reparto de leads

  • Quién recibe: empleados activos del departamento Ventas, más los que estén en eligibleEmployees. Hay dos condiciones: 1. Turno abierto en work_sessions. 2. Actividad en los últimos 5 minutos, sacada de presence_days vía pesosActivos.

La pausa dejó de contar el 12-oct. Fuera del horario configurado, recibe la lista de override_elegibilidad.

  • Modos (settings.modoReparto, lo cambia solo gerencia):
  • equidad, por defecto: uno a cada uno con saldo de grupo.
  • igualdad: baraja a mano entre los marcados, que se guarda con 600 ms de espera para no perder clics.
  • desempeno: leads nuevos de 14 días.
  • nivelacion: igual cantidad en la semana; solo en el servidor.
  • Flujo de repartir 1. Intenta POST repartirLeads {candidatos, tope:300}. 2. Si falla, calcula en el navegador con ejecutarReparto. Toma el lease de lock (90 s) y hace transacciones por bloque. 3. Cada bloque relee cada conversación, salta las que ya tienen dueño o dejaron de ser repartibles, y escribe {assignedTo, embudoVendedor:"", assignedAt, boardCol} junto con el libro mayor.
  • Repartible: no archivado, sin dueño, sin embudo final, no cliente y con ventana abierta.
  • Automático (settings.autoAsignar): cualquier pestaña abierta que vea un lead repartible espera 6 s para juntar ráfagas y reparte. Si nadie disponible, reintenta a los 90 s; si estaba ocupado, a los 10 s. El lease deja pasar a una sola pantalla.

Llamadas

  • Bitácora de WhatsApp: whatsapp_calls, últimas 60. Pendiente = missed y sin devueltaPor. "Devolver" abre el chat y llama a makeCall(). Solo si la llamada salió marca devueltaPor y devueltaEn, y pone llamadaPendiente:false.
  • Llamada documentada (#evLlamDocModal)
  • Reclamar o soltar escribe llamAsignada*.
  • El formulario pide resultado (compro, le_interesa, no_contesto, no_interesa, otro_momento), duración en minutos (0 a 180) y segundos (0 a 59), pantallazos y nota opcional.
  • La duración es obligatoria salvo con no_contesto. Se aceptan hasta 4 pantallazos, que se suben a 1.600 px JPEG al 85% a Storage llamadas_documentadas/{phone}/.
  • Crea llamadas_documentadas {phone, por, comentario, pantallazos[], resultado, duracionSeg, google, asignadaA, creadoEn} y escribe llamDoc* en la conversación. Si el lead tenía reloj, también llamGHecha, llamGAt, llamGPor, llamGSeg y llamGResultado.
  • Las reglas exigen llamGPor = mi usuario.
  • Si la llamada fue por WhatsApp, el servidor ya dejó el registro con tipo:"whatsapp" y solo falta el resultado (c2LdCompletarWa).
  • "No es cliente" pide 15 caracteres o más. Escribe llamGExento* y un registro tipo:"no_es_cliente".
  • Las llamadas registradas aparecen dentro del hilo como notas internas.
  • Llamada obligatoria
  • El servidor arranca el reloj en whatsappSend con la primera respuesta humana de Ventas a un lead nuevo de google_ads, directo o sitio_web con sourceAt desde el 17-oct.
  • Plazo: 3 horas.
  • Estados del chip: ventana, pendiente, hecha, compró y exento. Basurero y Empleo quedan exentos.
  • Escalera por la vencida más vieja: menos de 30 min es aviso; 30 min a 2 h avisa cuánto falta para el bloqueo; más de 2 h, el servidor responde 423 a todo envío salvo a los leads que se deben.
  • El aviso en pantalla sale como mucho una vez por hora por usuario (localStorage llamGAvisoAt_<usuario>) y siempre se puede cerrar.
  • Solo cuentan para aviso y bloqueo las que vencen desde el 30-oct 00:00 de Costa Rica (LLAM_G_BLOQUEO_DESDE, repetido en index.js y en el embudo).

embudo.html con crm2.js: el tablero gerencial

Es un kanban con una vista Chat y una vista Embudo (?view=), y un modo ficha (?ficha=<tel>) para mostrar un solo chat dentro de un iframe en Automatizaciones.

Columnas (columnasDelTablero, de js/wa-embudos.js, lista compartida): Sin asignar, Clientes, una por vendedor y los embudos finales:

  • Empleo (más Prospecto y Descartado)
  • Rural, Recuperador 3000, Garantías, Cotizaciones
  • Raspa 1, Raspa 2, Raspa definitivo, No raspar más
  • Leadsales, Basurero y No compró

Conteos y paginación

  • Cada columna se cuenta con getCountFromServer por boardCol.
  • Recuento con espera de 1,5 s y techo de 60 s. Ajuste optimista de ±1 al mover, o +1 si createdAt tiene menos de 5 minutos.
  • Muestra 10 tarjetas por columna; "Cargar más" pagina de a 10 por boardCol.
  • asegurarMinimos garantiza 10 tarjetas aunque ninguna haya entrado en el stream caliente.

Acciones

  • Arrastrar tarjetas (c2DropVendor, c2DropSinAsignar) o el menú "Mover a…".
  • Jalar en lote, solo gerencia, con writeBatch de 400:
  • Empleo: source:"ad" con "empleo" en el nombre de la campaña o del anuncio. No toca embudos finales ni los que ya tienen empleoJaladoAt.
  • Míos, Sin asignar y Garantías.
  • Filtro de ventana (más de 12 h, menos de 12 h, plantilla), filtro "solo clientes" y filtro de recordatorios vencidos.
  • "Origen hoy": cuenta los leads con entrante desde las 5 a. m. por canal.

permitirCambioDeEmbudo deja a cualquiera entrar o salir de Basurero. Para mover el lead de otro hace falta ser gerencia.

leads.html: formularios del sitio (proyecto secundario)

Inicializa una segunda app de Firebase apuntando al proyecto del sitio y escucha toda la colección contactos con onSnapshot, sin límite.

Pantalla

  • Teléfono a buscar y filtros de estado, asignado, calidad (alta, media, baja o sin), origen (formulario-principal, plaga-express, chatbot), categoría y rango Desde/Hasta (con T00:00:00 para no arrastrar leads de las 6 p. m. del día anterior).
  • KPIs: total, sin reclamar, hoy, semana y conversión (vendidos sobre total).
  • Vista tabla (50 por página, ordenable) y vista pipeline con arrastrar.

Estados: sin_reclamar, contactado, interesado, cotizando, vendido, no_interesa y numero_invalido.

Categoría (clasificarLead), comparando contra las fechas de cita en analitica_clientes del proyecto principal, con corte de 180 días:

  • lead: nunca fumigó.
  • convertido: primera cita después del lead.
  • recuperado: volvió con 6 meses o más de hueco.
  • recompra: volvió con menos de 6 meses de hueco.
  • excliente: compró antes, nada después, más de 6 meses.
  • activo: compró antes, nada después, menos de 6 meses.

Consulta clientes/{tel} y analitica_clientes/{tel} en tandas de 20.

Prioridad (0 a 5): 8 dígitos, correo, ubicación, mensaje de más de 20 caracteres y ya es cliente.

Escrituras (con setDoc merge sobre contactos/{id})

  • estado.
  • asignadoA y reclamadoPor, guardados como username.
  • calidad.
  • Comentarios en contactos/{id}/comentarios {texto, autor, ts}.
  • Arrastrar un lead libre fuera de "Sin reclamar" lo reclama para quien arrastra y deja el comentario "Lead reclamado.".

El detalle muestra las citas del teléfono: citas por telefono_normalizado y luego por telefono, 50 como máximo.

Lección del 26-sep: esto era una fuga. Los formularios del sitio caían en el proyecto secundario y solo una parte llegaba al CRM. Hoy el sitio manda además a webLead (sección 9). leads.html queda como bandeja histórica o paralela.

leads_b2b.html: directorio B2B

Datos base

  • GET getLeadsB2bData (en functions/index.js).
  • Valida: solo GET, verifyEmpleado, correo del dominio corporativo, ficha en empleados (por username o por firebaseAuthUID) y permisos.leads_b2b === true.
  • Devuelve require("./leads_b2b_data.json") con Cache-Control: private, no-store.
  • El JSON vivía en public/ y se servía sin sesión; por eso se movió a la function.

Gestión: leads_b2b_crm/{id}, un documento creado la primera vez que alguien trabaja el lead. loadCrmOverlay lo lee una vez al abrir la página.

  • Estados: pendiente, contactado, cotizado, reunion_agendada, ganado y perdido.
  • Campos: estado, asignadoA (id del empleado), asignadoANombre, proximoSeguimiento (YYYY-MM-DD), ultimoContacto{canal,resultado,ts,usuario}, historial[], reuniones[], cotizaciones[], actualizadoEn y actualizadoPor.
  • historial[] guarda {tipo, texto, usuario, ts ISO, canal?, resultado?} con tipo en nota, cambio_estado, reunión, cotización, correo, contacto, asignación, seguimiento o adjunto.

Tabla: columnas id, estado, organización, asignado, seguimiento, último contacto, contacto, cargo, correo, tipo de correo, fijo, celular, vertical, subvertical, GAM, fuente, confianza y notas. Filtros por vertical, subvertical, tipo de correo, GAM, fuente y confianza, más filtros rápidos de contacto. 100 por página.

Reglas de estado automáticas

  • Registrar contacto (llamada, WhatsApp, correo, visita o reunión): con resultado no_interesa pasa a perdido; si estaba pendiente, pasa a contactado. Nunca hace retroceder un estado.
  • Agendar reunión: de pendiente o contactado pasa a reunion_agendada.
  • Subir archivo (hasta 20 MB, a Storage leads_b2b_cotizaciones/{id}/): solo si el tipo es cotización, de pendiente o contactado pasa a cotizado.
  • Antes de decidir se relee el documento (releerCrm). Antes un lead ganado volvía a Contactado por decidir sobre la foto de la carga.
  • Un doble clic duplicaba la entrada, porque arrayUnion no deduplica objetos con distinto ts. Se evita con bandera y botón deshabilitado.

tools/leads-b2b/ (fuera de la web)

  • preparar-tanda.js arma una tanda y la audita: correo ASCII válido, dominio corporativo (no webmail), al menos un teléfono, coherencia entre sector y vertical, afinidad entre dominio y nombre, saludo usable, sin repetidos y que no se le haya escrito antes según el CRM.
  • enviar-correo.js <id> <slug> [--prueba]: lee el lead, personaliza la plantilla del segmento (public/email-assets/segmentos/segmentos.json), envía por SMTP con imágenes incrustadas (cid) y copia a gerencia comercial. Escribe historial y estado:"contactado" por REST con un token OAuth.
  • enviar-tanda.sh <lista> <slug> <segundos>: un proceso por lead, verifica asunto y destinatario, se detiene ante el primer desajuste y deja bitacora-envios.log.
  • nombre.js pasa la razón social a un nombre comercial (quita sufijos societarios y respeta siglas y palabras de enlace).

Reportes y analítica del CRM

  • whatsapp-analitica.html
  • Rango: hoy, 7, 30, 90 días o personalizado.
  • Carga toda whatsapp_conversations, los mensajes del rango y todos los mensajes anteriores (para saber quién inició cada chat). Es muy costosa; no conviene replicarla así.
  • Venta = citas por telefono_normalizado in (tandas de 30) con fecha_dia mayor o igual a la del primer contacto y monto mayor que 0. Si el lead no vino de anuncio, se exige que el primer mensaje haya sido entrante.
  • Tiempo de respuesta: del entrante al primer saliente, con tope de 24 h. El responsable es sentBy o, si falta, el dueño.
  • Evaluación con IA: POST whatsappEvaluate con hasta 15 conversaciones de 30 mensajes por empleado. Devuelve puntaje, fortalezas, oportunidades, ortografía, preguntas sin responder y ejemplos.
  • Usa STATUS_META del CRM viejo (nuevo, interesado…), que ya es legado.
  • desempeno_crm.html (con desempeno-crm.js)
  • Periodos de 7, 30 y 90 días. Caché en reportes_desempeno_crm/{dias}d con VERSION. Pagina de a 3.000 sobre whatsapp_activity (toda la historia), citas por fecha_creacion y whatsapp_messages.
  • Lead = primera asignación histórica de cada teléfono. Un pase entre vendedores no cuenta como lead. Los nombres se mapean en sus dos formatos (completo hasta julio, primer nombre después).
  • Métricas, todas con mediana además del promedio:
    • Respuesta al primer contacto y respuesta sostenida, con el reloj corriendo desde el primer entrante sin respuesta.
    • Abandonado: sin respuesta pasadas 24 h.
    • Ventanas perdidas: respuesta después de 24 h.
    • Conversión por fecha de creación de la cita, posterior a la asignación.
    • Ciclo de venta, recurrencia, toques por lead y aviso de cobertura.
  • Exporta CSV.
  • leads_por_hora
  • sourceAt >= 00:00 de Costa Rica de hace N−1 días, con N en 14 por defecto. Solo canales con esLead en shared/canales.js.
  • Excluye anuncios de empleo con RE_EMPLEO aplicado a campaña, titular y cuerpo.
  • Usa UTC−6 fijo y una sola escala de color de 6 niveles. Las horas futuras de hoy quedan vacías. El promedio solo cuenta días completos.
  • reporte_horas_leads: grilla de 7 por 24 (lunes = 0) sobre sourceAt. Marca las horas fuera del horario de override_elegibilidad. Promedia por cuántas veces ocurrió cada día de la semana en el periodo.
  • bot_ventas.html (vista rápida): pestañas Propuestas (bot_ventas_propuestas con estado=="elegida"), Leads (bot_ventas_leads, activos en vivo), Conversación (mensajes y bot_ventas_log), Tarifario (bot_ventas_tarifario), Configuración (bot_ventas_config/settings) y Métricas (bot_ventas_metricas). Las acciones van por POST botVentasAdmin {accion, params}. Desde el embudo, gerencia usa asignarLeadBot y previewVentasBot, que no envía nada.

functions/messenger.js: Messenger como lead

  • Diseño: las mismas colecciones de WhatsApp, con id fb_<psid>, canal:"messenger" y source:"messenger". El prefijo ig_ queda reservado para Instagram. Todo lo que asume que el id es un teléfono tiene que saltarlo con esIdMeta: plantillas, marcar leído, citas, extranjero, llamar y permiso de llamada.
  • sincronizarMessenger: corre cada minuto, con una sola instancia, 120 s de tiempo máximo y dos secretos (token de página e id de página). 1. Candado en messenger_sync/estado.corriendoDesde, vencido a los 4 minutos. 2. Cursor estado.cursor. La primera vez trae solo las últimas 24 h. 3. Pide a la Conversations API platform=messenger con los últimos 25 mensajes por conversación y pagina hasta llegar a updated_time <= cursor. 4. Por mensaje: si whatsapp_messages/{m.id} existe, lo salta. Los adjuntos se copian a Storage whatsapp_media/fb_<psid>/ con token de descarga, porque las URL de Meta caducan. 5. Un saliente que no está guardado se marca sentBy:"meta_inbox" (alguien respondió desde la bandeja de Meta). 6. En la conversación escribe lastMessage* solo si avanza, lastInboundAt, windowExpires (+24 h), unreadCount += entrantes y desarchiva. 7. Si la conversación es nueva: esCliente:false, totalFacturado:0, clienteAlEntrar:false, source:"messenger", subSource:"messenger_organico", sourceAt, assignedTo:<vendedora fija> (configurable en messenger_sync/estado.asignarA), fueraDeReparto:true sin assignedAt y boardCol. 8. Guarda ultimaCorrida o ultimoError.
  • Envío (enviar, que llama whatsappSend cuando el id empieza con fb_): POST a la página con messaging_type:"RESPONSE". Texto hasta 2.000 caracteres, o adjunto por URL. Un adjunto con texto sale en dos mensajes. No admite plantillas, ubicación ni contactos.
  • Pendiente: hasta que Meta apruebe el permiso avanzado de mensajería, responder desde el CRM falla. Por eso todo Messenger va fuera del reparto, a una sola persona.

functions/web-leads.js y atribución en el webhook

Código W de visita

El sitio genera W-xxxx (3 a 12 caracteres alfanuméricos) por visita y añade "Ref: W-xxxx" a todo enlace de WhatsApp.

webClick (solo POST, 60 por minuto por IP en memoria) maneja cuatro eventos:

  • Registro de visita: web_clicks/{W} con landing, referrer, título, utm*, gclid, wbraid, gbraid, fbclid, msclkid, dispositivo, idioma, pantalla, vid, vn, v1, experimento (exp) y geo (distrito por IP con base local; la IP no se guarda). Si trae un click id de Google, también escribe gads_clicks/G-<mismo> por compatibilidad.
  • evento:"wa_click": waClickAt, waClicks++, waPagina y waTexto.
  • evento:"tel_click": telClickAt, telClicks++, telVendedor y telNumero.
  • evento:"tel": telemetría en paginas.<pv> (segundos, scroll, segundos por sección, clics y pasos del cotizador), cotCanton y cotProv. Reenviar la misma vista pisa, no suma.

Atribución en el webhook

Pseudocódigo de lo que hace el webhook de WhatsApp cuando se puede asignar el canal. La condición es sourceAsignable (no hay source, o es saliente) y que no traiga referral de Meta.

sourceAt = ahora; clienteAlEntrar = hay primera cita antes de ahora (analitica_clientes)
clas = clasificarPrimerMensaje(texto)        # primera regla que calza
click = web_clicks[Ref W]
si Ref G:            source = youtube si la visita es de YouTube, si no google_ads
                     subSource = clic_pagado_google o anuncio_video_youtube; trae gclid de gads_clicks
si no, click con gclid/wbraid/gbraid: igual, googleAdsRef = "G-"+W
si no, click con msclkid:  bing / clic_pagado_bing
si no, click:              sitio_web, subSource = webSubcanal
si no, Ref W sin registro: sitio_web / sitio_sin_registro
si no, clas.source:        clas
si no, "vengo del sitio web": sitio_web / boton_sitio
si no:                     directo (+ subSource de clas) + directoTipo = precargada | saludo | no_texto | propio
sitio_web y comején → webSeguimientoPendiente = true, webLeadAt
source == empleo → assignedTo = <reclutamiento>, embudoVendedor = __empleo__, boardCol = __empleo__

Si ya había canal, una visita nueva solo deja webRefUltimo; el primer toque queda congelado.

Reglas de texto (REGLAS_TEXTO), en orden:

Texto Resultado
"les escribo desde google" google_ads / boton_whatsapp_google
RE_EMPLEO empleo
"vengo de instagram", "facebook", "google maps" subcanal correspondiente
"vengo de tiktok y quiero una cotización" tiktok / anuncio_whatsapp
Otro "vengo de tiktok" tiktok / perfil
QR del vehículo, factura subcanal correspondiente
"vengo del sitio web", "del cotizador", "del paquete" sitio_web / boton_sitio
Textos de anuncios de Meta sin referral ad / anuncio_sin_referral
"cuánto cuesta fumigar una casa" texto_precio_casa (queda en directo)
"es seguro para mascotas" pregunta_frecuente

subcanalDeVisita(click):

  • YouTube si utm o referrer lo indican.
  • gclid, wbraid o gbraid: clic_pagado_google. msclkid: clic_pagado_bing.
  • Maps (utm gbp o referrer de maps), meta_organico, google_organico, otro_buscador, tiktok, whatsapp.
  • utm_<x>, sitio_<host> o directo_al_sitio.

tipoPagina reconoce portada, plaga, zona, blog, precios, planes, idioma y otra.

webLead: formulario o chat con IA

Rechazos y límites:

  • Solo POST. 10 por minuto por IP en memoria y en web_leads_limites/ip_<ip>_<minuto>.
  • Honeypot empresa: si viene lleno, responde ok sin hacer nada.
  • Teléfono normalizado obligatorio.
  • Un lead por teléfono cada 24 h (tel_<phone>.leadAt, si no responde 429 "repetido").
  • Orígenes válidos: formulario-principal, plaga-express, zona-express y chat. Lo demás se guarda como formulario.

Mira analitica_clientes para saber si ya era cliente y abre una transacción:

  • Conversación nueva: lastMessageAt, lastInboundAt, lastMessageDirection:"inbound", lastMessage "📝 Solicitud web: <plaga> en <zona>" y unreadCount+1. Los campos de la visita. source = google_ads si había click id de Google, bing si había msclkid, si no sitio_web. subSource = formulario o chat. Además clienteAlEntrar, totalFacturado, esCliente, sinVentana:true y boardCol en Clientes o Sin asignar.
  • Ya existía: no toca canal, dirección ni no leídos, porque eso le quitaría el lead por turno a quien ya lo atiende. Solo escribe webLeadAt, webLeadOrigen, webSeguimientoPendiente, webContexto, desarchiva y pone el nombre si faltaba.

Después:

  • Plantilla de bienvenida: solo si la conversación es nueva, si no hubo plantilla en 24 h y si web_leads_config/settings.plantillaActiva. Va con sentBy:"sistema.web" y variables primer nombre y "plaga en zona".
  • Burbuja de contexto en whatsapp_messages/weblead_<tel>_<minuto>, como entrante con webLead:true. Dice que no hay ventana, nombre, plaga, zona, página, vía, mensaje y transcript, y cómo entrarle.
  • webPlantilla{enviada,error}.

seguimientoWebLeads (cada 15 minutos)

Revisa hasta 300 conversaciones con webSeguimientoPendiente. "Respondió" = hay un saliente humano (no bot, no sistema.web) después de webLeadAt.

  • Si pasaron 30 minutos o más, es horario de ventas (8 a 21 h en Costa Rica) y no hay webAlertaRapidaAt: estampa la bandera antes de crear la nota. Si nadie respondió, crea notas_mentales/webrapida_<tel> para el dueño, o una nota a gerencia si no tiene.
  • A las 48 h o más:
  • Sin respuesta: nota webseg_<tel> "mandale la plantilla o llamalo".
  • Comején con respuesta a las 72 h o más: nota para confirmar la inspección.
  • Lo demás con respuesta: se apaga la bandera.
  • Si no puede leer el hilo, lo deja para la próxima corrida en vez de suponer.

EXPERIMENTOS (A/B del sitio) solo se validan acá. Los resultados viven en web_experimentos/{id}. Nunca reusar un id.

Ciclo de vida del lead visto desde el tablero (campo por campo)

Paso Qué pasa Campos que cambian (todos en whatsapp_conversations salvo que se diga otra cosa)
1. Llega por WhatsApp El webhook crea el mensaje y la conversación lastMessage*, lastInboundAt, unreadCount++, windowExpires (24/72 h), source, subSource, sourceAt, clienteAlEntrar, campos web*/ad*/gclid. Si es empleo: dueño de reclutamiento y boardCol:"__empleo__". boardCol:"__sin_asignar__" o "__clientes__"
1b. Llega por formulario o chat del sitio webLead Lo de la tabla 8.3: sinVentana:true, webSeguimientoPendiente:true, burbuja en whatsapp_messages, plantilla opcional
1c. Llega por Messenger sincronizarMessenger id fb_<psid>, asignado a una persona fija, fueraDeReparto:true
1d. El vendedor lo crea a mano Modal Nuevo contacto source:"nuevo_contacto_embudo", assignedTo = el vendedor, actividad contacto_creado y asignado
2. Se asigna Reparto manual o automático assignedTo, embudoVendedor:"", assignedAt, boardCol. Libro mayor asignaciones24h. Actividad autoasignado. El trigger estampa primerAsignado, primerAsignadoAt y primerAsignadoFuente
2b. Cambia de dueño Cesión, reclamo, gerencia, recontacto en frío o turno assignedTo, fueraDeClientes:true, boardCol. Por turno además reasignadoTurno{} y reasignaciones_turno. Actividad cedido, asignado o reclamo
3. Se responde whatsappSend whatsapp_messages saliente con sentBy. lastMessageDirection:"outbound". unreadCount:0 y unreadPrev desde el navegador. En leads nuevos de Google, Directo o Sitio, el servidor arranca llamGInicio, llamGVence (+3 h) y llamGResp
3b. Se llama Modal de llamada llamadas_documentadas. llamDocHecha/At/Por/N. llamGHecha/At/Por/Seg/Resultado, o llamGExento*. llamAsignada*
3c. Seguimiento Recordatorio followUpAt y followUpNote
4. Se cotiza Precio por chat y menú Categoría embudoVendedor:"__cotizaciones__" y boardCol
5. Se agenda o compra Cita en agenda.html citas{metadata.vendedores, metadata.monto}. stampClienteOnCita: analitica_clientes.total_facturado/total_citas y esCliente:true, totalFacturado, boardCol:"__clientes__". llamGCompraOnCita: llamGCompro, llamGComproAt
6. Se enfría Pasan 24 h sin entrante No se escribe nada: categoriaDe lo pasa a Raspa en el navegador. Hasta 3 días sigue visible en "Todas"
6b. Se raspa Plantilla de recontacto raspadoAt, raspadoPor. Posible assignedTo del que raspa (sin dueño y sin citas, o frío más de 3 días vía servidor). raspadoVenta lo pone el backfill
6c. Se descarta Menú Categoría embudoVendedor = __raspa_no_mas__, __basurero__ o __no_compro__. Sale de "Todas"
6d. Cliente que vuelve Recuperador La plantilla de recuperador escribe recuperadorEnviado:true y boardCol:"__recuperador__". Si escribe de nuevo, se puede sacar de Clientes (ventana abierta) con fueraDeClientes:true

CRM: lecciones que hay que respetar al reconstruir

  1. Una regla en un solo lugar. - boardCol tiene dos copias (navegador y servidor) que deben coincidir. - puedeAsignar refleja firestore.rules, que es la que manda. - LLAM_G_BLOQUEO_DESDE y RE_EMPLEO están repetidos en varios archivos. - Si cambia una copia, hay que cambiar todas.
  2. Una regla que se evalúa después de un envío tiene que ignorar ese envío. El bug de lead frío del 3 al 9-oct nunca reasignaba porque lastMessageAt ya era "ahora".
  3. El teléfono se fija al abrir cada modal (plantilla, ceder, reclamo, llamada, ubicación, contacto). Lo mismo con el borrador por chat y los renders programados.
  4. Lo que el servidor puede rechazar se avisa con el motivo real. Antes, por ejemplo, la plantilla salía y el cambio de dueño moría en un console.warn.
  5. Costo de lecturas. - Nada de colecciones enteras en vivo. - Contar con getCountFromServer. - Categorías bajo demanda. - Pausa de streams con la pestaña oculta. - Presencia con getDocs. - whatsapp-analitica.html y leads.html siguen violando esta regla.
  6. El webhook nunca rebobina lastMessageDirection. La reasignación por turno lo lee; un reintento fuera de orden le quitaba el lead a quien lo atendía.
  7. El primer toque queda congelado (sourceAt, source, primerAsignado). Las métricas de canal y de conversión dependen de que no se pisen.
  8. esCliente se estampa al vender, así que no sirve para separar a los clientes que vuelven. Para eso hay que usar clienteAlEntrar o totalFacturado previo.
  9. Los ids que no son teléfono (fb_, ig_) necesitan guardas en todo lo que cruce con citas, plantillas o llamadas.
  10. Idempotencia con id fijo por minuto en las burbujas web, las notas de seguimiento y las plantillas automáticas. La bandera se escribe antes de la nota.

Reparto de leads: mapa general

Colecciones del núcleo:

Colección / doc Uso
whatsapp_conversations/{phone} El lead. Campos clave: assignedTo, assignedAt, boardCol, embudoVendedor, archived, esCliente, esClienteManual, totalFacturado, source, sourceAt, clienteAlEntrar, primerAsignado, primerAsignadoAt, primerAsignadoFuente, lastInboundAt, lastMessageAt, lastMessageDirection, windowExpires, reasignadoTurno, fueraDeClientes, llamG*
whatsapp_autoassign_config/settings modoReparto, autoAsignar, barajaIgualdad[], barajaUltimo, eligibleEmployees[], lastAssignedTo, minPeso, minutosActivo, minVerdeReciente
whatsapp_autoassign_config/asignaciones24h Libro mayor (el nombre viene de cuando era de 24 h, hoy se usa con 72 h): buckets{username:{horaUTC:n}}, grupo{firma,desde,conteos,ultimos} (solo cliente), ventanaHoras, reconstruidoAt
whatsapp_autoassign_config/lock Lease del reparto del navegador
whatsapp_autoassign_config/nivelacion_candado Candado del servidor para Nivelación
whatsapp_autoassign_config/override_elegibilidad activo, usuarios[], horario[7] (strings de 24 caracteres '1'/'0', lunes=0)
reparto_nivelacion/{username} {on, por, en} cola ON/OFF
presence_days/{username}_{YYYY-MM-DD} Minutos por slot: slots{idx:{t,k,a}}, day, tzOffset. Verde = k>0 (clic, tecla, scroll); ámbar = a>0
presence_live/{username} lastBeat (late cada minuto)
work_sessions Turno: status:"working", username/userId, startTime, endTime
roles_ventas/{lunes} {semana, temprano, nocturno, domingo, sabado, actualizadoPor, actualizadoEn}
whatsapp_activity Bitácora: phone, action (asignado, autoasignado, empleo_asignado, reasignacion_masiva, cola_nivelacion), details, performedBy, timestamp

Canales (shared/canales.js, en espejo en functions/shared/canales.js): entran al reparto ad (Meta), google_ads, youtube, bing, tiktok, sitio_web, directo. Messenger cuenta como lead pero se asigna directo a una persona y no entra al reparto. saliente, empleo y nuevo_contacto_embudo no son leads.

functions/wa-asignacion.js: reparto en el servidor

Por qué existe: antes cada pestaña calculaba su cola y dos personas veían órdenes distintos. Ahora hay una sola respuesta. El módulo se exporta como fábrica construir({onRequest, corsOptions, verifyEmpleado}) y index.js lo cablea.

Endpoints (HTTP, región us-central1)

Función Auth Recursos Body
preverReparto cualquier empleado 120 s, 512 MiB {candidatos[], cuantos} (cuantos entre 1 y 50, por defecto 10)
repartirLeads gerencia o permisos.embudo_vendedor 300 s, 512 MiB {candidatos[], tope} (tope entre 1 y 500, por defecto 200; el embudo manda 300)
reconstruirHistorialReparto solo gerencia 540 s, 512 MiB nada

Se subió a 512 MiB porque el modo Desempeño lee 14 días de conversaciones y citas, y con 256 la función moría.

Selección del modo

modoDe(cfg):
  "igualdad"   -> "baraja"
  "desempeno"  -> "desempeno"
  "nivelacion" -> "nivelacion"
  otro / vacío -> "72h"   (en la UI se llama "equidad")

Solo gerencia puede escribir modoReparto: lo exige la regla de Firestore, que affectedKeys no incluya modoReparto salvo esGerencia().

Elegibilidad de los modos 72h, Baraja y Desempeño

Hacen falta las dos condiciones:

  1. Turno abierto: existe un work_sessions con status == "working".
  2. Actividad: al menos 1 minuto verde (slot.k > 0) en los últimos 5 minutos (ACTIVIDAD_MS = 5 min, MIN_VERDES_RECIENTES = 1).

La pausa (whatsapp_unavailable) dejó de sacar gente desde el 12-oct-2026 porque quedaban pausas abiertas de un día a otro. Igual quedó un texto viejo, "sin pausa", en el mensaje de motivo.

Cómo se leen los minutos verdes (actividadEnVentana):

  • Días a leer: todos los días CR que toca la ventana de 72 h, más uno hacia atrás. diaStr(ms) = ISO(ms - 6h), porque el navegador escribe presence_days con la fecha local (en UTC se perdían hasta 6 h entre las 6 pm y la medianoche).
  • Instante de cada slot: slot.t, o si falta, Date.UTC(día) + tzOffset*60000 + idx*60000.
  • Se cuentan minutos distintos (Math.floor(t/60000) en un Set) y no latidos: con varias pestañas un minuto se repetiría.
  • Se descarta t < ahora-72h o t > ahora+120 s.
  • Cada exclusión viaja con su motivo ("sin turno", "sin actividad en 5 min") para que el panel lo explique.

Libro mayor de 72 h

  • Formato: buckets[username][horaUTC] = n, con horaUTC = floor(ms/3.600.000).
  • leerConteos: suma los buckets con hora >= horaUTC(ahora-72h).
  • Después de cada reparto, una sola transacción hace podarYSumar y graba con set(..., {merge:true}).
  • reconstruirLedger: lee whatsapp_conversations where assignedAt >= ahora-72h y arma los buckets desde assignedTo/assignedAt. Se corrió una vez al pasar de 24 h a 72 h.

Modo "Últimas 72 h" (planificar72h)

ratio(u) = leads72h(u) / max(horasActivas72h(u), 5/60)
si horasActivas < 1/60 y leads == 0: ratio = -1   // recién llega, va primero

para cada lead i en 1..N:
  ordenar pool por (ratioSim asc, ultimo asc, username asc)
  elegido = primero
  sim[elegido] += 1 ; ultimo[elegido] = ++tick
  • Ventana móvil: lo que pasó hace 73 h ya no pesa. Reemplazó al "saldo por grupo", que se reiniciaba cada vez que alguien abría o cerraba turno. El 21-set-2026 una vendedora recibió 78 de 82 leads del día por eso.
  • El ratio se recalcula después de cada entrega simulada. Calcularlo de una vez desbalanceaba la cola a los pocos leads.
  • El -1 vale solo mientras el recién llegado tiene 0 leads simulados. Antes acaparaba la cola entera.
  • Piso del divisor (MIN_HORAS_PISO = 5 min): sin él, el primer lead daba un ratio desorbitado. Ojo: con ese piso, quien recién recibe su primer lead queda con ratio 12 y pasa al fondo de la cola.
  • El detalle devuelve también margen = ratio del segundo - ratio del primero.

Modo "Baraja" (planificarBaraja)

lista = cfg.barajaIgualdad
desde = lista.indexOf(cfg.barajaUltimo) + 1
orden = (lista[desde..] + lista[..desde]) ∩ aptos
paso i -> orden[i % len]
  • Reciben solo los marcados. Antes se sumaba al final a todo apto que no estuviera en la baraja, y desmarcar a alguien no lo sacaba.
  • El turno sigue desde barajaUltimo, que se graba en settings al terminar. Sin eso, empezar siempre por el primero le daba casi todo al de arriba.
  • Exige igual las dos condiciones de elegibilidad.

Modo "Desempeño" (desempenoLeadsNuevos + planificarDesempeno)

Ventana de 14 días, con caché en la memoria de la instancia por 10 min (clave = usernames ordenados + días).

  1. Leads: whatsapp_conversations where sourceAt >= hace 14 d (con select, límite 20.000), agrupados por assignedTo. Se excluye source == "messenger".
  2. Ventas: citas where fecha_creacion >= hace 14 d (límite 30.000), sin Ruta 20 y con monto > 0. Se cruzan por los últimos 8 dígitos del teléfono (clave8). Se toma la primera cita por teléfono.
  3. Rapidez: por cada lead, whatsapp_messages where conversationPhone==tel and timestamp>=entró order asc limit 12, en lotes de 25 en paralelo. Antes se barría toda la colección de mensajes y se pasaba del límite de memoria. Se toma el primer mensaje que no sea inbound, bot, type:"call" ni sentBy:"sistema.web". El reloj arranca en desdeCuando: si el lead entró con turno abierto, desde que entró; si entró fuera de turno, desde el startTime del siguiente work_sessions (cambio del 8-oct). Al vendedor se le asigna la mediana.
  4. Notas 0 a 10 contra el mejor del equipo: - nCierre = cierre / maxCierre * 10 - nCol = colonesPorLead / maxCol * 10 - nResp = minResp / resp * 10; sin datos de respuesta, 5 - índice = 0,30·nCierre + 0,50·nCol + 0,20·nResp
  5. Cuota con piso 5 % y techo 30 %: crudo = índice/suma*100, recortado a [5, 30]. Después, hasta 60 iteraciones para que sume 100: el faltante se reparte proporcional al índice solo entre quienes tienen margen (si hay que subir, los que no están en el techo; si hay que bajar, los que no están en el piso). Al final se redondea a entero y la diferencia va al de mejor índice que no esté en el techo, o al peor que no esté en el piso. Si la suma de índices es 0, todos parejo. Sin topes, dos personas se llevaban el 62 %.
  6. Planificación por déficit:
totalYa = Σ sim + 1
déficit(u) = share(u)/100 - sim(u)/totalYa
recibe el de mayor déficit; empate: ultimo, luego nombre

El punto de partida (sim) son los conteos de 72 h del libro mayor. A propósito no es "los mejores primero": eso le daría toda la ráfaga al puntero.

Leads repartibles

esRepartible(c) = !archived && !assignedTo && embudoVendedor ∉ {__perdido__,__ganado__,__descartado__}
                  && esCliente !== true && source ∉ {empleo, proveedor}
leadsSinAsignar(tope): where boardCol == "__sin_asignar__" order lastMessageAt desc limit tope

repartirLeads en los modos 72h, Baraja y Desempeño

  1. Lee la config y decide el modo.
  2. Calcula los elegibles. Si no hay, responde motivo y no reparte.
  3. Lee los leads sin asignar y arma los pasos con cuantos = leads.length.
  4. Por cada lead, una transacción: relee la conversación; si ya tiene dueño o dejó de ser repartible, se salta. Si no, escribe {assignedTo, embudoVendedor:"", boardCol:destino, assignedAt:serverTimestamp}.
  5. Graba el libro mayor en una sola transacción y, en Baraja, guarda barajaUltimo.
  6. Responde {asignados, modo, ventanaHoras, detalle[{phone,destino}], fuera, porVendedor}. La bitácora (autoasignado) la escribe el navegador con lo que devuelve el servidor.

preverReparto hace el mismo cálculo sin escribir. Devuelve detalle de todos los candidatos (leads, horas, ratio, apto, motivo y, en Desempeño, índice, cuota, leadsNuevos, ventasNuevas, cierrePct, colonesPorLead, respMin), ordenado por ratio.

Rama Nivelación dentro de repartirLeads

  • Candado nivelacion_candado de 90 s con ficha aleatoria, tomado en transacción. Si lo tiene otro, responde {ocupado:true} y el embudo reintenta a los 10 s. Se suelta en un finally, y solo si la ficha es la propia.
  • El equipo lo arma el servidor (candidatosDelEquipo); la lista que manda el navegador se ignora.
  • Leads: los repartibles, más esClienteManual !== true, más ventanaAbierta. Se ordenan por lastInboundAt/lastMessageAt ascendente: el que más espera va primero.
ventanaAbierta(c):
  si lastInboundAt < 24 h -> sí
  si lastMessageDirection=="inbound" y lastMessageAt < 24 h -> sí   // llamadas sin lastInboundAt
  si hay lastInboundAt -> no
  si no -> windowExpires > ahora
  • Se decide lead por lead con el resultado real:
suma = sumaAlRecibir(lead)          // lead nuevo y sin primerAsignado
si suma: ordenar por (n asc, ult asc, nombre)
si no:   ordenar por (ult asc, n asc, nombre)   // no suma: al que lleva más sin recibir
transacción: relee, valida y escribe assignedTo/boardCol/assignedAt
  y, si no hay primerAsignado: primerAsignado, primerAsignadoFuente "reparto_nivelacion", primerAsignadoAt
si ok: e.n += suma ? 1 : 0 ; e.ult = ahora + tick
corta si pasaron más de 75 s (CANDADO_MS - 15 s); lo que falte sale en la próxima vuelta

functions/wa-nivelacion.js: modo Nivelación semanal

Objetivo: que de lunes a domingo (hora CR) todos los de la cola terminen con la misma cantidad de leads nuevos. No usa nada de los otros tres modos: ni turno, ni actividad de 5 min, ni libro mayor, ni baraja, ni índice.

Elegibilidad (elegibles), con el motivo en este orden:

  1. Franja de asignación entre 8:00 y 21:59 (HORA_ABRE=8, HORA_CIERRA=22). Fuera de eso nadie recibe.
  2. El rol de la semana está en turno (rolEnTurno).
  3. Está encendido en la cola (reparto_nivelacion/{u}.on).
  4. Está en línea: presence_live/{u}.lastBeat en los últimos 3 min.

Roles (ROLES, días con 0 = domingo; intervalos [desde, hasta)):

Rol L a V Sábado Domingo
diurno 8 a 17 8 a 15 no
temprano 8 a 16 (trabaja desde las 7, recibe desde las 8) 8 a 15 no
nocturno 12 a 22 no no
domingo 8 a 17 no 8 a 17
sabado (Sábado tarde) 8 a 17 14 a 21 no

rolesDeLaSemana: lee roles_ventas/{lunes}. Quien no tiene rol es Diurno. Los comentarios de cabecera de varios archivos dicen "Diurno 8 a 5 L-S"; manda el código, que tiene la excepción del sábado.

Regla única de "lead nuevo" (copiada tal cual en conversion_vendedores.html; si se cambia en un lado hay que cambiarla en el otro):

esLeadNuevo(x) = source ∈ CLAVES_REPARTO && clienteAlEntrar !== true
                 && !(source=="ad" && RE_EMPLEO.test(adCampaignName|adHeadline|adBody))
RE_EMPLEO = /anuncio de empleo|vacante|quiero aplicar|enviar (mi )?curr[ií]culum|hoja de vida|\bempleo\b|reclutamiento/i
duenoDelLead(x) = lower(primerAsignado || assignedTo), descartando *.bot
fechaDelLead(x) = max(sourceAt, primerAsignadoAt)   // el de domingo 11 pm repartido el lunes es del lunes

Conteo semanal (conteoSemanal): dos consultas, sourceAt >= lunes 00:00 CR y primerAsignadoAt >= lunes, unidas por id. Cuenta por dueño si es lead nuevo y desde <= fecha <= ahora+60 s. ultimos[u] guarda la fecha más reciente.

candidatosDelEquipo(cfg): empleados con departamento "ventas" (normalizado, sin tildes) y estado distinto de "inactivo", más cfg.eligibleEmployees (sin bots ni inactivos). Caché de 5 min.

planificar: orden por (n asc, ult asc, nombre), sumando +1 después de cada paso. prever simula como mínimo 30 pasos y devuelve el detalle por persona (on, onPor, online, lastBeat, semana, rol, apto, motivo).

Notas operativas:

  • Cualquier empleado prende o apaga a cualquiera en la cola. Del 13 al 15-oct hubo 11 apagados a compañeros. Gerencia tiene esa decisión pendiente.
  • En el navegador no hay cálculo propio para este modo: si el servidor no contesta, no se reparte.
  • La regla de Firestore bloquea escribir el lock del cliente cuando el modo es Nivelación.

Disparo desde el embudo y public/wa-reparto.js (respaldo del navegador)

Disparo automático (embudo-vendedor.js):

  • settings.autoAsignar es el interruptor de todo el equipo. Gerencia lo cambia directo; el resto tiene que confirmar.
  • Cada lead nuevo entra a autoPendientes. programarAuto espera AUTO_ESPERA_MS = 6 s para agrupar las ráfagas.
  • correrAuto revalida los pendientes y llama a repartir({silencioso:true, soloEstos}).
  • Los que quedan sin dueño se reintentan a los 90 s, o a los 10 s si el servidor respondió ocupado.
  • repartir siempre prueba primero el servidor (repartirLeads con candidatos: eligibleVendors()). Si responde bien, registra autoasignado en whatsapp_activity usando el primer nombre del destino.
  • Si el servidor falla y el modo no es Nivelación, se usa el cálculo local de wa-reparto.js.
  • Ojo: con soloEstos y un modo distinto de Nivelación, el automático se salta el servidor y va directo al cálculo local.

Cálculo local, modo Igualdad: ejecutarReparto(..., {equitativo:true}) con peso 1 para todos y contadores en cero, entre los marcados. No mira presencia.

Cálculo local, modo Equidad (grupo):

  • Elegibles: turno abierto y actividad reciente (minVerdeReciente, por defecto 1, en minutosActivo = 5 min). Ambos se pueden afinar en settings sin desplegar.
  • Fuera de horario de atención: si override_elegibilidad.activo y la celda horario[díaLunes0][hora] no es '1', el pool pasa a ser usuarios[] y no se exige ni turno ni actividad. El equipo puede mover solo usuarios; activo y horario son de gerencia.
  • Saldo por grupo grupo{firma, desde, conteos, ultimos}, donde firma son los usernames ordenados y unidos con |. Si el pool cambia, quien sigue conserva su saldo y quien entra arranca en el mínimo de los que siguen (antes se reseteaba a cero, ver el caso del 21-set).
  • Planificador Sainte-Laguë: score = (asignados + 0,5) / peso, gana el menor. En modo grupo el peso es 1 para todos. Desempate: ultimos (más tiempo sin recibir), luego el conteo de 24 h, luego el nombre. Quien acaba de recibir pasa al final de los empates.
  • Lease lock de 90 s con sessionId. Cada bloque de 100 leads va en una transacción que relee el lock (si cambió de dueño lanza LEASE_PERDIDO), relee cada conversación, escribe, actualiza buckets y grupo, y renueva el lease. El lease se suelta en pagehide y en finally.
  • Peso proporcional (legado, el que había antes del grupo): w = max(minPeso=30, minutosVerdes24h), sin techo a propósito.
  • registrarAsignacionManual(username, cedidoPor): suma +1 a quien recibe y −1 a quien cedió en los buckets y en el grupo. Lo llaman el embudo y crm2.js en cada asignación a mano.

Datos de contexto para el panel:

  • contarAsignados7d: primera asignación por teléfono en whatsapp_activity. Excluye los marcados empleo_asignado y los pases ("X" → "Y" cuando X es un vendedor). Cruza por primer nombre, así que dos vendedores con el mismo primer nombre se sumarían juntos.
  • facturacionDeLeads: citas por fecha_creacion posteriores a la asignación.
  • minutosTrabajados.

public/wa-asignacion.js: de quién es un lead

puedeAsignar({duenoActual, destino, currentUsername, esGerencia}):

  • Sin dueño: lo toma cualquiera.
  • Mismo destino: no cambia nada, ok.
  • Gerencia: siempre ok.
  • El dueño puede soltarlo (destino vacío), cederlo a otro o mandarlo a Empleo.
  • Cualquier otro caso: "solo gerencia puede reasignarlo" (o "solo el dueño o gerencia pueden devolverlo").

EMPLEO_DUENO es un username fijo de reclutamiento, fuera de la rotación. Las reglas de Firestore (soltandoLoMio, cediendoLoMio) son las que mandan; este archivo solo da el mensaje antes de intentar.

Reasignaciones que no son del reparto

La reasignación por turno (tomarLeadPorTurno), el lead frío (reasignarLeadFrio) y el primer asignado (estamparPrimerAsignado) están descritos en las secciones del núcleo de WhatsApp; acá solo se agrega la herramienta manual.

reasignar-leads.html + .js (gerencia; en check-permissions la página pide embudo_vendedor, pero la pantalla bloquea a quien no es gerencia por departamento o cargo)

Sirve para arreglar repartos que salieron mal. Flujo: rango desde/hasta (hora local) por sourceAt, no por assignedAt; filtro por quién lo tiene hoy (cualquiera, sin asignar o un vendedor) y vendedor destino (Ventas activos).

  1. "Ver cuáles son" busca y marca todos. Cada fila se puede desmarcar.
  2. Al aplicar, los que ya son del destino se saltan. Se escribe en writeBatch de a 400: {assignedTo, assignedAt, assignedBy, reasignadoEnBloque:true, updatedAt}.
  3. Queda un registro reasignacion_masiva en la bitácora.

El botón "Rango del caso" pone domingo 4 pm hasta hoy 7 am. Hay un mapa de calor de 7×24 con los leads de 7 días, en una sola escala de 6 niveles para todas las celdas, y marca las horas fuera de override_elegibilidad.horario.

Importante: esta herramienta no toca boardCol, embudoVendedor ni el libro mayor.

Roles y turnos: horarios_ventas.html + horarios-ventas.js

Permiso de página: whatsapp o embudo_vendedor. Desde el 13-oct reemplaza la hoja de medias horas; la colección vieja horarios_ventas quedó sin uso.

  • Tabla: una columna por vendedor de Ventas activo y una fila por semana, de PRIMER_LUNES = 2026-09-14 a ULTIMO_LUNES = 2027-12-27. Las semanas pasadas se ocultan salvo que se active "ver pasadas".
  • Edición (solo gerencia, por regla y en pantalla): un select por celda. Al elegir un rol se le quita a quien lo tenía esa semana (pasa a Diurno). Si la persona tenía otro rol, ese queda en null. Se graba con setDoc merge y actualizadoPor/actualizadoEn. Si falla, la pantalla vuelve al estado anterior.
  • Orden de columnas: según la baraja, es decir, quién entra primero a Temprano desde la semana siguiente. Así el ciclo se ve como una escalera.
  • Cobertura por hora: grilla de 7 am a 9 pm × 7 días con caras por persona. Niveles: 0, 1, 2 a 3, 4 o más. Muestra cuántas horas quedaron sin nadie. Usa HORARIO, con Temprano entrando a las 7.
  • Pie: cuenta de T/N/S/D por persona.

Ciclo de 6 semanas (desde el 26-oct-2026): Temprano, Nocturno, Sábado tarde, Domingo, Diurno, Diurno. Seis vendedores desfasados una semana, cargado hasta el 27-dic-2027. Si entra o sale alguien, se rehace desde la semana siguiente; el largo del ciclo es igual al número de vendedores.

Los horarios de los roles están repetidos en cuatro lugares y hay que cambiarlos juntos: public/horarios-ventas.js (HORARIO), functions/telefono-ventas.js, functions/wa-nivelacion.js y functions/alertas-llegadas.js.

functions/telefono-ventas.js: número de llamadas del sitio público

Problema que resuelve: el sitio mostraba en todos los botones el número de empresa de un solo vendedor, y le caían todas las llamadas.

  • telefono_ventas/{lunes} = {semana, generadoEn, vendedores, dias:{"0".."6":{"0".."23":[{u,t}]}}}. Las horas van como objeto porque Firestore no acepta un arreglo dentro de otro.
  • Candidatos: candidatosDelEquipo con teléfono de empresa, o personal si no tiene. Solo números de 8 dígitos que empiezan con 2 a 8 (se quita el 506).
  • Hora sin nadie en turno: se busca hasta 48 h adelante, usando los roles de la semana siguiente si hace falta. En la práctica: noche entre semana, el Temprano; sábado en la noche, el rol Domingo; domingo en la tarde, el Temprano de la semana siguiente.
  • Disparadores: telefonoVentasDiario a las 5:00 CR (esta semana y la siguiente). telefonoVentasOnRol al escribir roles_ventas/{lunes} (esa semana y la anterior). telefonoVentasOnEmpleado solo si cambia teléfono, estado, departamento o username de alguien de Ventas.
  • Endpoint GET telefonoVentas (CORS de los dominios del sitio, máximo 5 instancias, 15 s, Cache-Control max-age=120): responde {semana, dow, hora, fijo, ahora, elegibles, dias}. Desde el 24-oct ahora trae solo a los elegibles de Nivelación en ese momento (horario, rol, cola ON, en línea; memo de 60 s); si no hay ninguno, la lista del turno.
  • El sitio elige al azar entre los de ahora, una vez por visita y hora (sessionStorage). Usa la tabla en localStorage de respaldo y, si todo falla, deja el número fijo. El clic se registra en web_clicks (tel_click).

Reportes del equipo

conversion_vendedores.html (permiso embudo_vendedor)

  • 16 semanas cerradas (lunes a domingo) más la semana en curso (VD_SEMANAS = 16), plegadas por defecto. Las abiertas se guardan en localStorage cvSemanasAbiertas. Hay un botón "Solo esta semana".
  • Filtro de canal: Todos, Meta (ad), Google (google_ads + youtube), Directo, Sitio web, TikTok, YouTube.
  • Datos: una consulta por canal de reparto where source==X and sourceAt>=desde, más primerAsignadoAt>=desde, unidas. Solo esLeadNuevo. Dueño = duenoDelLead, día = fechaDelLead. Solo cuentan los dueños del departamento Ventas. Hay una fila oculta por pedido de gerencia; sus leads siguen sumando al equipo.
  • Venta del lead: alguna cita del teléfono con monto > 0, sin Ruta 20, inicio_gmt6 >= sourceAt y creada después de sourceAt.
  • Tablas (mismas columnas): % de cierre y leads, con color en escala de 5 pasos; tiempo de respuesta en minutos, mediana con un decimal, sacada de tiempos_respuesta/{día} (JSON en datos, calculado cada 30 min por tiemposRespuestaRecientes); llamadas obligatorias de 3 h (campos llamG*); y teléfonos de empresa (wa_equipo_leads_dia desde el 16-oct, con detalle en wa_equipo_leads).
  • Gerencia pidió semanas fijas y no ventanas móviles, filas compactas de una línea y que entre sin scroll a 1400 px.

eficiencia_vendedores.html + eficiencia-vendedores.js (permiso reportes_gerenciales)

  • Periodos de 7, 30 y 90 días, calculados a pedido con "Calcular ahora". Caché en reportes_eficiencia/{N}d con VERSION = 2; una caché de otra versión se recalcula.
  • Vendedores: estado Activo con permiso embudo_vendedor, crm o comisiones.
  • Primera asignación sobre TODA la historia de whatsapp_activity, paginada de a 3000 desde 2020. Leer solo la ventana hacía pasar por "primera vez" al 7,2 % de los leads.
  • Destino = el último nombre entre comillas, o el texto después de "→". Si el origen es un vendedor, es un pase y no cuenta. Se mapean el primer nombre, el nombre completo y el username, porque el formato de la bitácora cambió en julio de 2026: mapear una sola forma perdía más de la mitad de los leads.
  • Venta: citas por fecha_creacion (cuándo se vendió) posterior a la asignación, con monto distinto de cero.
  • Pivot: "propio" si el receptor está en metadata.vendedores; "ajeno" si la cerró otro (o no tiene vendedor). En lo ajeno se detalla quién cerró. Se calcula conversión, por lead (monto ÷ todos los leads) y ticket (monto ÷ los que compraron).
  • Si la primera asignación registrada es más reciente que el periodo pedido, la cobertura sale "parcial" con los diasReales.

mis_ventas.html (permiso ventas_calle)

  • Citas donde el usuario es vendedor: metadata.vendedores array-contains u + inicio_gmt6 >= primer mes (índice compuesto). Si el índice falla, filtra por fecha. Ventas desde el 1-feb-2026.
  • Mes operacional: fecha_mes, o fecha_dia cortado, o la fecha calculada con corte a las 5 am.
  • Reparto de la venta entre vendedores (porcentajesPorVendedor, igual que el táctico y el snapshot): si excepcion_pesos_vendedores tiene tantos elementos como vendedores y suma 100, se usan esos pesos; si no, partes enteras iguales y el resto va a los primeros.
  • Oficina o calle: oficina si algún vendedor de la cita tiene vendedor_leads_pauta === true. Ruta 20 se excluye.
  • Comisión:
comisiónBruta = max(0, oficina/1,13 - meta_ventas_base) × comision_ventas_oficina_pct/100
              + calle/1,13 × comision_ventas_calle_pct/100
neta = bruta × (1 - 0,1083)        // descuento de CCSS

La base se descuenta una sola vez sobre el total del mes. Después se prorratea entre ganadas (cita ya pasó) y pendientes según el peso de cada una en las ventas. Antes se restaba dos veces y mostraba ₡0.

  • Ranking Fumigación: departamento Fumigación con algún work_sessions en los últimos 7 días, por ventas de calle del mes. Lee todas las citas del mes (fecha_mes) solo si hay candidatos.

Mini táctico: mini-tactico.html + functions/mini-tactico.js

  • Desde el 30-oct-2026 es el único táctico de los vendedores (permiso mini_tactico, hereda de reportes_gerenciales). Gerencia usa tactico.html (comisiones). tactico-ventas.html es solo un redirect al mini.
  • Endpoint miniTacticoDatos?mes=YYYY-MM (permiso mini_tactico o comisiones, 1 GiB, 120 s): arma una foto por mes con field mask (CAMPOS_CITA) y la guarda en tactico_ventas_cache/{mes} como {v:2, generadoEn, datosGz}, en gzip.
  • No se guarda si pasa de 900 KB comprimida.
  • Vigencia: 5 min para el mes anterior en adelante, 6 h para los meses más viejos.
  • Además hay caché en la memoria de la instancia y deduplicación de pedidos simultáneos.
  • El historial se recorta: solo los cambios de inicio_gmt6 (en ms), los cambios de monto de las canceladas y la última entrada si no hay ultima_edicion.
  • Al vendedor le llegan solo sus citas: citas del mes y recientes = mes en curso más los 2 siguientes, y el mes anterior solo si el día es 15 o menos. Respuesta gzip, private, no-store.
  • Página: porcentajesPorVendedor está envuelta para devolver solo la parte del usuario (una cita 50/50 aparece con la mitad). porcentajesPorVendedorTodos se usa para mostrar con quién se vendió. Bloques: verde (ventas por día operacional, sin meta ni avance), rojo (canceladas), azul (reagendadas), hoy y 7 días. El ranking va sin posición, medalla ni totales.

Foto diaria de ventas: snapshot-ventas.js + snapshotVentasDiario

  • Horario: 23:45 CR todos los días (540 s, 1 GiB). Fotografía el mes en curso y el anterior.
  • Criterio (el mismo del indicador del táctico): rutas 1 a 19, monto > 0 y día operacional (corte a las 5 am, GMT-6) dentro del mes.
  • Lee citas con inicio_gmt6 del mes más un mes de margen a cada lado, para que una cita reagendada no aparezca como "desaparecida".
  • snapshots_ventas/{YYYY-MM}_{díaToma} = {total, citasContadas, citasEnElMes, porVendedor, porRuta, porCanal, porDia, citasJSON, criterio}. El detalle va como texto JSON: como mapa superaba el límite de entradas de índice por documento.
  • Señales (revisarSenales): compara la foto de hoy con la anterior. Avisa cuando desaparece una cita, cuando una cita ya prestada pasa a Ruta 20 o a ₡0, o cuando le bajan el precio más de 20.000. Las alertas salen como notas_mentales a gerencia (hasta 4 destinatarios), máximo 10 avisos por mensaje.
  • snapshotVentasNow (gerencia): ?mes=&dia= para rellenar o tomar una foto a mano.

Rollups del servidor

rollupVentasMensuales (POST, permiso reportes_gerenciales, máximo 1 instancia, 540 s)

Alimenta reporte_tactico.html ("Estrategia Macro", solo largo plazo).

  • Modo: incremental desde el mes anterior (GMT-6), o completo desde 2023-01 si se pide modo:"full", si no existe _meta o si _meta.version < ROLLUP_VENTAS_VERSION (2). Si la última corrida tiene menos de 5 min, se salta.
  • Lectura: citas paginadas de a 10.000 con select (inicio_gmt6, telefono, ruta, metadata.monto, metadata.ruta). Ruta 20 con monto suma a cancelCnt/cancelMonto; el resto con monto > 0 entra a las filas.
  • Estado congelado: 10 shards _pv_0.._pv_9, elegidos por el último dígito del teléfono, con tels{tel:mesPrimeraCompra} y compras{tel:"YYYY-MM:monto,..."}.
  • ventas_mensuales/{YYYY-MM}: ventas, nuevos, existentes, sinTel, activosCnt, nuevosCnt, citasCnt, cancelCnt, cancelMonto, pautaCrc (suma de pauta_spend meta, google, youtube, tiktok y bing), fuente.
  • _cohortes: cohortes[mesPrimera]{n, ret{j}, act{j}, ven{j}}; retMovil[t]{act12, base, ret} (12 meses contra los 12 anteriores, solo meses cerrados); anual[Y].
  • Los meses que quedaron sin ventas se ponen en cero. Los montos son de citas, no facturación contable.

ventasSync (cada 6 h)

  • Citas con inicio_gmt6 entre hoy −45 y +60 días, con monto, sin Ruta 20 y con teléfono de 8 dígitos o más.
  • Las marcas existentes se leen en lotes de 100 con getAll; antes, una lectura por cita no cabía en los 300 s.
  • Por cada candidata: busca whatsapp_conversations/{phone} con canal en {ad, google_ads, youtube, bing, tiktok, sitio_web, directo} y que la cita se haya creado después de sourceAt. Entonces escribe ventas_atribuidas/{citaId} con los ids de clic y del anuncio.
  • Si el canal es ad y hay adId (ctwa_clid), manda un evento Purchase por la API de conversiones de WhatsApp: event_id = purchase_{citaId}, teléfono con sha256, CRC. La bandera metaEnviado refleja la respuesta real, no el hecho de haber posteado.
  • Conciliación: si la cita se borró, pasó a Ruta 20 o quedó en ₡0, la venta atribuida se marca anulada (no se borra). Si vuelve a ser válida, se reactiva. Se actualizan monto y fecha. Lo que la plataforma ya importó no se retira.

functions/confirmacion-vencida.js

Regla (espejo de public/js/confirmacion-plazos.js): toda cita se confirma antes de las 3:30 pm del día anterior a su jornada. La madrugada (antes de las 5) pertenece a la jornada anterior. Rutas 11 a 17, desde el 10-set-2026: además del check hace falta el pantallazo (imagen_confirmacion). Los teléfonos que están en confirmacion_no_necesaria salen confirmados solos.

limite(inicio): día = día(inicio) - (hora<5 ? 1 : 0) - 1 ; a las 15:30 CR
confirmada = confirmada24_48 && (!pideFoto || imagen)
  • citasDelPlazo: fecha_dia entre mañana y pasado mañana, filtradas por limite == plazo.limiteMs. Excluye canceladas y Ruta 20.
  • 12:00 y 14:00 avisarPlazo: escribe alertas_confirmacion/{día}_1530 (pendientes, vendedores, totalCitas, avisoAt) y, si hay pendientes, encola un WhatsApp en wa_equipo_salida hacia el grupo de coordinación de ventas: un renglón por responsable con su mención y cuántas le faltan. Hay reglas fijas de responsable sustituto y los técnicos de Fumigación los responde el coordinador de ruteros. Candado anti-duplicado en avisoWhatsAppAt.aviso1/aviso2.
  • 15:30 aplicarPlazo: no actúa si faltan más de 2 min para el plazo; si faltan menos, espera. Por cada pendiente:
  • sube una constancia SVG a Storage calidad_venta/confirmacion_vencida/;
  • marca metadata.venta_no_calidad = true, con justificación (si ya estaba marcada a mano, se concatena), venta_no_calidad_evidencias y metadata.confirmacion_vencida{plazo, limiteMs, revisadoMs, motivo, avisada, constancia};
  • escribe ultima_edicion y entradas en extras/historial con el usuario "sistema (confirmación vencida)".

No repite si ya hay una marca del mismo día. Después avisa el resultado ("sin comisión", o "al día").

  • confirmacionVencidaNow (gerencia): accion=ver|avisar|aplicar; WhatsApp solo con &whatsapp=1.
  • Desde el 29-oct, firestore.rules solo deja tocar venta_no_calidad y sus campos a gerencia o a un usuario autorizado de calidad. Antes era solo una restricción de pantalla y los vendedores se desmarcaban.
  • Reversión del 29-oct: gerencia juzgó que el efecto era bajo y devolvió la comisión a las citas que seguían marcadas (confirmacion_vencida.revertida). La función sigue activa y vuelve a marcar cada día a las 3:30 pm.

functions/cita-a-ventas.js: envío de citas al grupo de Ventas

Endpoints:

  • enviarCitaAVentas (POST, empleado, 2 GiB, 180 s): {citaId, imagen (png base64, entre 1 KB y 8 MB), forzar}. Si ya hay ventas_envios/{citaId} y no viene forzar, responde 409 ya_enviada. Gerencia puede mandar para (teléfono de prueba).
  • ventasPantallazo (POST): accion:"preview" con un borrador (cita nueva) o con citaId+movida; accion:"movida" manda "Cita movida", etiquetando a los vendedores, y registra en ventas_movidas.
  • ventasAutoEnviar (onDocumentCreated citas/{id}, 2 GiB, 540 s, máximo 20 instancias) llama a automatico().

automatico(citaId):

cfg = wa_equipo_config/ventas_auto ; si activo === false -> "apagado"
esperar cfg.minutos (por defecto 3) minutos
releer la cita: existe, fecha_dia, monto>0, no Ruta 20, no es_ficticio, no cancelada,
  tiene vendedores, no existe ventas_envios/{id}
numero = numeroDe(primer vendedor)    // wa_equipo_cuentas asignado, conectado, sin soloDe,
                                      // con menor `orden`; si no hay, EcoBot
mandar(..., auto:true, para: cfg.para)

Justificación con datos: la mediana para apretar el botón era 1 min y el 97 % se hacía antes de 30 min; solo 1 de 458 citas se editó en los primeros 30 min. Va una imagen por cita, aunque se creen muchas seguidas (pedido explícito de gerencia).

mandar:

  • armarImagen (1620×1080 a escala 2):
  • citas del día y la ruta (fecha_dia, sin canceladas ni Ruta 20; sin ruta real, todas las del día);
  • tiempos de manejo con un servicio público de ruteo (OSRM) (steps + geojson, 8 s de timeout, tramos de ~80 puntos, mínimo 1 min; si falla, sale sin tiempos);
  • otras rutas del turno de fondo;
  • datos de la cita: lugar, "vez" según telefono_normalizado sin ficticias (Nuevo / 2ª vez / Nª de M / 30+), canal, servicios.

Mapa base de un proveedor comercial con llave guardada como secreto; si no hay llave, cae a teselas de OpenStreetMap.

  • Sube el PNG a Storage wa_equipo/{numero}/salida/, encola en wa_equipo_salida (tipo:"imagen", hd:true, creadoPor:"ventas.auto" o el username) con el texto "🛒 Venta de …", teléfono y enlaces cortos.
  • ventas_envios/{citaId} = {por:"automatico"|username, numero, cuando, salidas[], veces++}.

Reparto y gestión de ventas: hallazgos y riesgos para la reconstrucción

  1. El libro mayor de 72 h se trunca a 24 h. registrarAsignacionManual (embudo y crm2) graba buckets con tx.set sin merge y poda con la ventana de 24 h del cliente. Cada asignación o cesión manual borra las horas de 24 a 72 h que usa el servidor. Al reconstruir: unificar la ventana o mover ese registro al servidor.
  2. Las cesiones restan en el servidor. El cliente escribe −1 al cedente. sumarBuckets del cliente lo deja en mínimo 0, pero el del servidor no: en el modo 72h una cesión baja el conteo de quien cedió.
  3. La poda del servidor no borra nada. Usa set(..., {merge:true}) sobre el mapa anidado, que Firestore fusiona en profundidad, así que las horas viejas no se eliminan. Solo las filtra sumarBuckets; el documento crece hasta que el cliente lo reescribe.
  4. La auditoría de revisarSenales nunca avisa. La consulta es documentId >= mes+"_" y < mes+"_", un rango vacío, así que siempre devuelve null. Debería ser < mes+""o un sufijo\uf8ff`.
  5. El automático puede saltarse el servidor. Fuera de Nivelación, el disparo automático (soloEstos) no llama a repartirLeads y usa el cálculo local (saldo por grupo, Sainte-Laguë). En la práctica el automático y el manual pueden aplicar reglas distintas a las que muestra preverReparto (72h).
  6. Desempeño mide distinto que el resto. Cuenta por assignedTo actual (no primerAsignado), solo excluye Messenger y cruza la venta con la primera cita con monto por teléfono, sin exigir que sea posterior al lead. Es distinto de la regla de lead nuevo de Nivelación y de conversion_vendedores.
  7. reasignar-leads deja el tablero inconsistente. No actualiza boardCol/embudoVendedor ni el libro mayor, así que el lead puede quedar en la columna vieja del tablero hasta que otro proceso la recalcule.
  8. Horarios de roles duplicados en 4 archivos, y además con textos de cabecera desactualizados respecto del sábado.
  9. La cola de Nivelación no tiene control de permisos: cualquier empleado apaga a otro. Queda registrado en la bitácora (cola_nivelacion), pero la decisión de restringirlo sigue pendiente de gerencia.
  10. Confirmación vencida sigue marcando todos los días después de la reversión del 29-oct. Si la política es no penalizar, hay que apagar los tres programadores.

Bot de ventas (Melissa y Adrián): mapa general

Las piezas del bot de ventas y dónde vive cada una:

  • functions/bot-ventas.js (unas 3.000 líneas). Tiene la máquina de estados, el armado del prompt, los topes, el opt-out, el handoff, los seguimientos y las acciones de administración.
  • functions/wa-envio.js. Hace el envío a la API de WhatsApp Cloud del lado del servidor, con idempotencia.
  • functions/geo.js. Convierte un pin, un link o unas señas en un punto con distrito, y mira la cobertura del atlas.
  • functions/optimus.js. Propone espacios de agenda cerca de citas existentes, revalida el espacio y crea la cita.
  • functions/index.js. Solo tiene wrappers que arman el ctx y conectan cada disparador.
  • Frontend: public/bot_ventas.html (el panel), public/js/agenda-fab-bot.js (el FAB y los bloques tentativos en la agenda) y public/embudo-vendedor.js (la vista previa y la asignación manual, solo para gerencia).

Hay dos personajes, que el código llama "brazos":

brazo username género quién lo recibe
melissa melissa.bot f teléfonos que terminan en dígito par
adrian adrian.bot m teléfonos que terminan en dígito impar

Si un brazo está apagado en la config, sus leads van al otro. Si los dos están apagados, el bot no reclama a nadie.

Principio de diseño, citado del código: "Todo lo que decide el flujo lo decide ESTE código, no el modelo". El modelo de lenguaje solo redacta los textos y extrae datos en un JSON con esquema. Quien fija el paso, el precio, el descuento y el espacio es el sistema. Por eso el modelo nunca ve el precio mínimo.

Integraciones externas:

  • Un modelo de lenguaje por API HTTP con salida JSON estricta (output_config.format de tipo json_schema). Hay dos niveles: el modelo principal de gama media y un clasificador liviano, que solo se usa para confirmar los opt-out.
  • La API de WhatsApp Cloud (Graph).
  • Un geocodificador público de OpenStreetMap, como máximo una llamada por segundo.
  • En el simulador, además, un proveedor de agentes de voz conversacionales (TTS más STT, con URL firmada).

Reloj y zona horaria: Costa Rica es UTC-6 fijo y no tiene horario de verano. El día operacional va de 05:00 a 04:59 del día siguiente. En la lógica de negocio nunca se usa Date.now() suelto, siempre ctx.ahora(), para que las pruebas puedan usar un reloj falso.

Bot de ventas: colecciones

colección id del documento quién escribe qué guarda
bot_ventas_config settings servidor (setConfig). La regla también deja escribir a gerencia interruptor, modo y topes (ver §3)
bot_ventas_tarifario clave de la tarifa gerencia, directo desde el panel política de precios (§6)
bot_ventas_ejemplos automático gerencia o script pares cliente/vendedor de conversaciones reales
bot_ventas_leads teléfono normalizado solo el servidor (regla write: false) estado de cada conversación del bot
bot_ventas_propuestas automático solo el servidor espacios ofrecidos, elegidos y aprobados
bot_ventas_log automático solo el servidor un registro por turno: entrantes, salida del modelo, costo, error
bot_ventas_metricas YYYY-MM-DD (día operacional) solo el servidor contadores del día, desglosados por brazo
bot_ventas_previews automático wrapper previewVentasBot auditoría de cada vista previa pedida

Lectura según las reglas:

  • Leads, log y métricas: quien tenga el permiso bot_ventas o reportes_gerenciales, o sea gerencia.
  • Propuestas: lo mismo, más el permiso logistica.
  • Config, tarifario y ejemplos: cualquier empleado.

Las siete colecciones bot_ventas_* están excluidas del bloque comodín de las reglas.

Colecciones ajenas que el bot lee o escribe:

  • whatsapp_conversations/{phone}. Lee assignedTo, optOut, botHandoff, embudoVendedor, esCliente, esClienteManual, source, sourceAt, lastMessageAt y lastInboundAt. Escribe assignedTo, assignedAt, boardCol, botActivo, botBrazo, botHandoff, botHandoffMotivo, botResumen, botHandoffAt, botPausado, botPausadoPor, optOut, optOutAt, raspadoAt, raspadoPor, raspadoPlantilla y lastMessage*.
  • whatsapp_messages. Lee el hilo. Escribe los salientes con bot: true, botBrazo y botPaso (más botSeguimiento, botReoferta o botConfirmacion según el caso).
  • whatsapp_activity. Escribe acciones como autoasignado, bot_handoff, bot_pausado, bot_optout, asignado, asignado_a_bot y liberado_de_bot.
  • analitica_clientes/{phone}.total_citas. Si es mayor que 0, el lead no es elegible.
  • whatsapp_autoassign_config/override_elegibilidad. Trae activo, horario y usuarios. El horario es una grilla de 7 filas de 24 caracteres, lunes primero; si una fila viene en medias horas (48 caracteres) se colapsa a horas.
  • whatsapp_autoassign_config/asignaciones24h. Es un libro de asignaciones por vendedor en cubetas por hora.
  • work_sessions (con status == working) y presence_live (con lastBeat de hace menos de 20 min): con estas dos se elige vendedor en horario.
  • empleados. Se leen los teléfonos para excluirlos, los permisos para los avisos y el primer nombre.
  • notas_mentales. Recibe los avisos a logística o gerencia, con el formato {usuario, creadoPor, asignadoA, texto, done, creadoEn}.
  • citas, clientes, ruteador y atlas_zonas: los usan Optimus y geo.
  • promesas_bloqueadas: la escribe wa-envio.

Documento de lead (bot_ventas_leads/{phone}), forma inicial:

phone, brazo, username, activo:true, paso:"nuevo", pasoAnterior,
datos:{ plagas[], tipoInmueble, m2, diasPreferidos[], audiosConsecutivos,
        provincia/canton/distrito (+Ids), lat, lng, precisionUbicacion,
        referenciaTexto, referenciaProbada, senasRecibidas, pendientePin,
        cubierto, zonaId, zonaNombre, nombreCliente, horarioPreferido },
cotizacion:{ tarifaKey, servicioId, duracionMin, precioLista, precioOfrecido,
             precioMinimo, pasoDescuento, valorAgregado, textoCotizacion,
             precioAceptado, intentosDescuento, aceptadoAt },
espacios[], espacioElegidoId, propuestaId, citaId,
seguimiento:{ intentos, proximoAt, ultimoAt, plantillasEnviadas },
handoff:{motivo,at}, handoffPendiente, lock:{holder,expiraAt}, hayPendiente,
wamidsProcesados[] (últimos 20), ultimoInboundAt, erroresSeguidos,
contadores:{ mensajesEnviados, turnos, tokensEntrada, tokensSalida, costoUsd, primeraRespuestaMs },
mensajesHora:{<hora epoch>:n}, turnosDia:{<YYYY-MM-DD>:n},
bucle:{ultimoBody,repeticiones,rapidos}, sombra, elegibleMotivo, referralAdId,
ultimoSalienteBotAt, ventanaCerrada, plantillaBloqueadaHasta, ultimoTurnoNoEntregado,
asignadoAMano, asignadoPor, intentosEspacios, repreguntasEspacio, createdAt, updatedAt

Lección del código sobre escrituras: set() con merge no parte las claves con punto; eso solo lo hace update(), y update() falla si el documento no existe. Por eso el helper anidar() convierte "seguimiento.proximoAt" en un objeto anidado antes de set(..., {merge:true}). Hay un caso borde: con setMerge, escribir cotizacion: {} no borra nada. Para limpiar hay que usar rutas planas ("cotizacion.precioOfrecido": null).

Bot de ventas: estados de la conversación (paso)

Estos son los estados reales que maneja el código:

  • nuevo: primer turno.
  • saludo y calificando: se piden plaga, tipo de inmueble y zona.
  • ubicacion: falta un punto, o falta el pin y las señas después de elegir fecha (datos.pendientePin).
  • cotizado: ya salió el bloque de precio, opcionalmente con 2 fechas.
  • objecion: se aplicó un descuento.
  • precio_aceptado: es transitorio y casi siempre desemboca en espacios en la misma vuelta.
  • espacios: hay opciones de agenda sobre la mesa.
  • propuesta: el cliente eligió y logística tiene que aprobar.
  • cita_creada.
  • Terminales: handoff, pausado_humano, optout, cerrado_sin_respuesta y fin_de_muestra.

PASOS_SIN_SEGUIMIENTO agrupa propuesta, cita_creada y todos los terminales.

Intenciones que el modelo puede devolver: pregunta, da_datos, pide_precio, acepta_precio, objeta_precio, pide_agendar, elige_espacio, ninguno_sirve, pide_humano, queja, empleo, garantia, empresa_formal, comejen_estructural, otro. Las que escalan siempre son pide_humano, queja, garantia, empleo, empresa_formal, comejen_estructural.

Función transicion(paso, intencion, lead) (pura, es código y no modelo):

si paso terminal distinto de handoff: se queda
si intención escala o salida.escalar.si: handoff
nuevo/saludo -> calificando
calificando/ubicacion:
   si ubicacion con pendientePin: con coords exactas o señas recibidas -> precio_aceptado; si no, ubicacion
   faltan datos (plaga + tipo + provincia|provinciaId|lat) -> calificando (o ubicacion)
   sin coords ni distrito -> ubicacion
   sin tarifa resuelta -> handoff (motivo sin_tarifa)
   -> cotizado
cotizado/objecion:
   objeta_precio: si intentosDescuento < 2 y precioMinimo < precioOfrecido -> objecion, si no handoff (negociacion)
   si había fechas con el precio: elige_espacio -> propuesta ; ninguno_sirve -> espacios
   acepta_precio|pide_agendar|elige_espacio -> precio_aceptado (con coords) o ubicacion
precio_aceptado -> espacios (con coords) o ubicacion
espacios: elige_espacio -> propuesta ; ninguno_sirve -> espacios si intentosEspacios < 2, si no handoff
propuesta: cualquier cambio (ninguno_sirve/elige/objeta/pide_agendar) -> handoff

Motivos de handoff que se registran: intención, modelo:<motivo>, sin_tarifa, negociacion, sin_espacio_util, cambio_tras_propuesta, post_cita, tope_diario, fin_de_muestra, audio_repetido, fuera_cobertura, precio_fuera_de_tarifario, sin_espacios, espacio_ambiguo, ia_no_disponible, ia_sin_credito, error_repetido, no_entregable, plantilla_pausada, payload_invalido, sin_ventana_para_reofertar, aviso_cita_fallido, tomado y 131048.

Bot de ventas: configuración y modos

Si el documento no existe o la lectura falla, el bot queda apagado. Caché: config, override, teléfonos excluidos y tarifario 30 s; ejemplos 5 min. Atlas 10 min, en geo.

clave default qué controla
activo false interruptor general
modo sombra sombra, fuera_horario, porcentaje o todos
porcentajeEnHorario 0 en modo porcentaje: hash FNV del teléfono % 100 menor al valor
brazos ambos true
autoAprobar false aprueba sola la propuesta si score ≥ 70 y el ruteador del día está armado
maxMensajesPorLeadHora 12 tope por cubeta de hora (solo gerencia lo cambia; máximo 60)
maxTurnosPorLeadDia 30 al llegar, handoff tope_diario
topeDiarioUSD 15 gasto en IA del día; al pasarlo el bot se apaga solo. Tope duro en código: 50 USD
porcentajeSombra 100 muestreo de leads nuevos, solo en sombra
maxLeadsNuevosDia 0 (sin tope) freno aproximado, lee metricas.leadsNuevos
maxTurnosPorLeadTotal 0 si se pasa: fin_de_muestra en sombra, handoff en real
debounceSeg 8 espera después de tomar el lock para juntar ráfagas
delayRespuestaSeg [15, 40] demora "humana" antes del primer envío
pausaEntreMensajesSeg [2, 5] pausa entre mensajes seguidos
modelo / modeloClasificador gama media / liviano solo gerencia; tienen que existir en la tabla de costos
maxSeguimientos 3
seguimientosHoras [2, 20, 48]
plantillaSeguimiento / plantillaConfirmacion una plantilla utility de agendar
plantillasPausadas {} la llena wa-envio con los errores 132015 y 132016
canalId "" canal de adquisición de la cita; sin él, aprobarPropuesta falla
radioKm 5 radio de búsqueda en Optimus y de revalidación de cercanía
diasVentanaOptimus 14
fechasConPrecio true el bloque de precio sale con 2 fechas reales
agendarConReferencia true se puede agendar con distrito o señas y pedir el pin después
usarListas false ofrecer los espacios como lista interactiva
numerosExcluidos [] más los teléfonos de todos los empleados (los de 11 dígitos o más)
textoHandoff* "Permítame un momento para revisarlo bien y le confirmo 🙏"; fuera de horario: "Le confirmo a partir de las {hora} 🙏"
promptExtra "" se agrega al final del system
apagadoPor / apagadoAt tope_diario, sin_credito o 131048

Los modos:

  • sombra: reclama el lead en bot_ventas_leads con sombra: true usando create() (no toca la conversación), corre el modelo y guarda en el log lo que habría dicho. No envía nada. Corre aunque activo sea false.
  • fuera_horario: solo reclama si el override de elegibilidad dice que es fuera de horario. Si el override está apagado, todo cuenta "en horario".
  • porcentaje: fuera de horario reclama todo; en horario, solo el porcentaje que caiga del lado correcto del hash. Como el hash depende del teléfono, el mismo cliente cae siempre del mismo lado.
  • todos: reclama todo lead de pauta.

Quién cambia qué: setConfig usa una lista blanca (solo claves que existen en CONFIG_DEFAULT). El panel manda soloPauta, pero se ignora: la regla de exigir referral es fija. Las claves topeDiarioUSD, modelo, modeloClasificador y maxMensajesPorLeadHora exigen ser de gerencia, y solo cuentan como tocadas si el valor cambió (el formulario manda todas las claves en cada guardado). Al encender desde el interruptor se limpian apagadoPor y apagadoAt.

Bot de ventas: elegibilidad y reclamo de un lead

El orden de los rechazos es este. El primero que falla se escribe en el log de consola.

  1. botHandoff true
  2. optOut true
  3. assignedTo con valor
  4. esCliente o esClienteManual
  5. embudoVendedor con valor
  6. analitica_clientes.total_citas mayor que 0
  7. No es el primer mensaje. Se exige |sourceAt − timestamp| < 120 s y |lastMessageAt − timestamp| < 1 s.
  8. Sin referral de anuncio. Se exige referralAdId, referralSourceId o source == "ad". Las conversaciones orgánicas siguen siempre con personas.
  9. El modo: en_horario, porcentaje o fuera_de_muestra.

Después se aplica el tope maxLeadsNuevosDia y se elige el brazo por paridad.

En modo real, el reclamo es una transacción sobre la conversación: si ya tiene dueño, botHandoff u optOut, aborta. Si no, escribe assignedTo = <brazo>.bot, boardCol, botActivo y botBrazo, y crea el lead. En sombra se usa create() sobre el lead, porque dos primeros mensajes en el mismo segundo disparan dos triggers y un set() pisaría el lock del primero. Si el reclamo falla porque otro trigger ganó, se lee el lead existente y se sigue con él para no perder el entrante.

Un lead entregado (botHandoff) nunca se vuelve a reclamar. Un lead en cerrado_sin_respuesta que vuelve a escribir se reactiva: vuelve a pasoAnterior (o a calificando) y los intentos de seguimiento quedan en 0.

Asignación manual (gerencia): asignarLeadABot se salta toda la elegibilidad y siempre entra en modo real, aunque la config diga sombra o el bot esté apagado (asignadoAMano: true). Si ya hubo un lead con el mismo brazo, conserva datos, cotización, paso y contadores. Escribe en la conversación embudoVendedor: "", fueraDeClientes: true, botPausado: false y botHandoff: false. Si lo último del hilo es del cliente, contesta de inmediato sin debounce (con delay [0,2] y pausa [1,2]); si lo último lo escribió una persona, no interrumpe. soltarLeadManual apaga el lead (no lo borra), deja botPausado: true en la conversación y la asigna al destino o la deja sin dueño.

Las reglas de Firestore (esBotUsername) impiden que un vendedor ceda o asigne un lead a un *.bot. Solo gerencia puede.

Bot de ventas: turno completo

Entrada (trigger botVentasOnInbound)

Se dispara con la creación de un documento en whatsapp_messages/{msgId}. Configuración: memoria 512 MiB, timeout 300 s, retry: false. No se reintenta porque el módulo nunca relanza: registra el error y hace handoff, y un reintento le contestaría dos veces al cliente. Se ignora todo lo que venga de Messenger o de ids de Meta (el bot es solo de WhatsApp).

Si el documento es un saliente de una persona (no bot, y sentBy no es un bot), se llama a procesarOutboundHumano: pausa el lead (pausado_humano) y marca botPausado en la conversación. En sombra no hace nada.

Los entrantes de tipo call, reaction, system, order y unsupported se ignoran.

Presupuesto de tiempo: ctx.limiteMs = ahora + 270 s (el trigger corta a los 300). Todo lo que espera lo consulta. Márgenes: 30 s para otra vuelta y 60 s para regenerar la respuesta del modelo. La lección: si el turno se pasa, la función muere después de enviar y antes de guardar el paso, y el siguiente entrante repite la cotización.

Orden en procesarInbound: config, lead previo, la excepción de lead manual, excluidos, conversación, opt-out antes que nada y por último reclamo o reactivación. Luego llama a atenderLead.

Lock por lead y lotes

  • tomarLock (transacción). Si el lead está inactivo devuelve "inactivo". Si el lock está vigente y es de otro, marca hayPendiente: true y devuelve "ocupado". Si no, toma el lock por 300 s.
  • Quien encuentra el lock ocupado reintenta cada 5 s, hasta 12 veces, mientras quede presupuesto. Así ningún entrante depende de que el titular llegue a ver la marca.
  • El titular espera el debounce (8 s) y después entra en un bucle de hasta 10 vueltas: 1. Relee el lead y trae los entrantes desde ultimoInboundAt − 1 s (hasta 50), filtrando los que ya estén en wamidsProcesados. Meta manda timestamps en segundos enteros, así que dos mensajes del mismo segundo solo se distinguen por wamid. 2. Si hay un saliente humano posterior a la marca: pausa y sale. 3. Borrón para pruebas: si un entrante es exactamente ... o …, el hilo se corta ahí y se resetean datos, cotización, espacios, paso y seguimiento. Es por mensaje y no por teléfono, así no hay lista de números de prueba que mantener. 4. Detección de bucle: 3 entrantes iguales seguidos, o 3 seguidos que llegan a menos de 5 s del último saliente del bot. Se pausa sin responder (autorespuesta). 5. Relee el dueño. Si ya no es el bot: pausarSinResponder("conversacion_ajena"). 6. Espera hasta 20 s a que otra función descargue la mediaUrl de las imágenes. El bot nunca escribe mediaUrl. 7. Llama a procesarTurno con sesion = {msgId, marca, seq}. La marca (ultimoInboundAt, wamidsProcesados, bucle) se escribe junto con los cambios del turno y no antes: si el turno falla, los entrantes siguen pendientes. 8. soltarOContinuar (transacción): si entró algo pendiente mientras tanto, renueva el lock y da otra vuelta; si no, lo libera. En el finally siempre se intenta soltar, pero solo si el lock sigue siendo de este trigger.
  • escribirCambiosTurno escribe en una transacción solo si el lead sigue activo y el lock es de este msgId. Si no, descarta los cambios y deja turno_descartado en el log. Los mensajes ya salieron, pero el paso no se pisa.

Dentro de procesarTurno, en orden

  1. Topes. Mensajes en la hora mayor o igual a 12: corta sin responder y loguea tope_hora. Turnos del día mayor o igual a 30: handoff. Tope total de turnos: fin_de_muestra. Gasto del día (bot_ventas_metricas/{dia}.costoUsd) mayor o igual al tope en USD: apagarBot("tope_diario").
  2. Opt-out en cualquiera de los entrantes. Se detecta por: - un botón de plantilla con "no deseo", - un interactivo con id que empieza con optout_, - la regex del botón "no deseo más mensajes", - o texto que pasa la regex amplia (no me escriba, dejen de, quítenme, stop, baja...) y además lo confirma el clasificador liviano (50 tokens, 10 s, 1 intento). Así no se da de baja a alguien que escribió "baja" hablando de la casa de abajo.

El veredicto se recuerda por wamid en memoria (hasta 500) para no pagarlo dos veces. Efecto: la conversación queda con optOut: true, el lead pasa a optout y sale un mensaje de despedida fijo.

  1. Audios. El primero recibe un texto fijo ("Por acá no logro escuchar audios. ¿Me lo escribe por favor?"). El segundo seguido sin texto en medio va a handoff audio_repetido. Un texto o una foto reinician la cuenta.
  2. Ubicación. Se detecta un pin, un link de Maps o Waze, o señas. Las señas solo se interpretan si ya se pidió ubicación o si ya se conoce el distrito: antes de eso, "cerca del parque" es conversación. Se llama a geo.resolverUbicacionCliente con el contexto de distrito, cantón y provincia. Un punto aproximado nunca pisa un pin. Si queda fuera de cobertura del atlas, se manda un texto fijo ("Esa zona la coordinamos por gira...") y luego handoff silencioso fuera_cobertura. Las señas que no se pudieron geocodificar igual cuentan (senasRecibidas) y viajan a la cita en otras_senas.
  3. Pasos que se resuelven sin modelo: - En propuesta, si todos los entrantes son acuses ("ok", "gracias", 👍): texto fijo de "sigue pendiente de confirmación con logística". - Pin que llega con la fecha ya elegida (pendientePin más espacioElegidoId). Si la cita vecina sigue dentro de radioKm del punto real, se concreta. Si quedó lejos, se reofrece con el punto exacto ("Gracias. Con la ubicación exacta, lo que me queda más cerca es:"), o handoff si no hay opciones. - En cita_creada, una confirmación ("sí", "listo", "ok"...) marca metadata.confirmada24_48 en la cita, pero solo si la precisión es pin o link, y responde con un texto fijo. Cualquier otra cosa va a handoff post_cita. - En elección (espacios, o cotizado/objecion con fechas ya ofrecidas): una respuesta de lista o un número suelto ("2", "el 1", "opción 3") concreta la elección directo.
  4. Imágenes y modelo. Las imágenes entran como bloques del último turno de usuario, por URL o en base64 si pesan 5 MB o menos (bajadas con el token si todavía no hay URL). Si solo llegaron fotos y fallaron todas: texto fijo pidiendo que la reenvíe. Después se cargan el tarifario, los ejemplos del paso y el hilo, y se corre el modelo.
  5. Fusión de datos. La salida del modelo nunca puede tocar lat, lng, los ids, cubierto, zona*, precisionUbicacion, monto ni precio. Las plagas se canonizan y se unen sin repetir. Si hay distrito o cantón pero no punto, se usa buscarDistritoPorTexto (precisión distrito, en el centroide). Si el modelo extrajo referenciaUbicacion, se geocodifica una sola vez por texto (referenciaProbada).
  6. Filtro de montos (se explica en §7). Si aparece una cifra ajena, se regenera una vez con "no menciones ningún monto"; si persiste, handoff precio_fuera_de_tarifario.
  7. Tarifa y transición. En calificación, con los datos completos y sin tarifa, elegirTarifa arma la cotizacion con precio ofrecido igual al de lista. Luego se llama a transicion.
  8. Efectos según el paso nuevo:
    • Primer turno: el saludo oficial lo pone el sistema, nunca el modelo. Si el modelo saludó en un texto corto, ese texto se descarta. La plantilla del saludo es: hola, le atiende {nombre}, asesora o asesor de la empresa, cotización sin compromiso, "¿Cuáles plagas presenta?" (se omite si ya la dijo) y "Provincia, cantón y distrito". Lección de la etapa de sombra: el modelo saludaba con "Hola, con gusto" y nunca decía quién atendía, y la comparación entre los dos brazos depende de que el nombre salga siempre igual.
    • cotizado: el código arma el bloque de precio con textoCotizacion y {precio}, y del modelo queda solo una frase. Si fechasConPrecio está activo, hay punto usable y quedan más de 30 s, se piden 2 espacios a Optimus y se agrega "Y le adelanto que tengo espacio en su zona: 1)... 2)... ¿Cuál le queda mejor?".
    • objecion: nuevo precio = máximo entre el mínimo y (ofrecido − pasoDescuento, 5000 por defecto), y un intento más. Texto fijo: "Le podría dejar el servicio en ₡X y le incluyo {valorAgregado}. ¿Le parece?". Como mucho hay 2 bajadas, y después va a handoff.
    • Precio aceptado sin punto: paso ubicacion, pendientePin, y un texto fijo que pide el pin o las señas.
    • Precio aceptado con fechas ya vistas: se repiten las opciones sin volver a Optimus.
    • precio_aceptado o entrada nueva a espacios: se envían los textos, se piden 3 espacios y se manda la lista. Sin espacios: handoff sin_espacios.
    • Elección: concretarEleccion. Si el modelo dice que eligió pero el id no está en la lista, se repregunta; nunca se pasa a propuesta sin una propuesta real. "Ninguno sirve" o una hora pedida reofrecen evitando esos días, o priorizando la hora pedida. Una segunda ambigüedad va a handoff espacio_ambiguo.
    • Si Meta marcó el turno anterior como no entregado (ultimoTurnoNoEntregado) y el paso no cambia, se antepone el bloque de precio o la lista otra vez.
  9. cerrarTurno. Contadores (mensajes en la hora, tokens, costo, primeraRespuestaMs), programación del seguimiento, escritura transaccional, log y métricas del día por brazo.

concretarEleccion. Elegir fecha cuenta como aceptar el precio. Si la ubicación es aproximada y no hay señas, primero se piden el pin o las señas ("Perfecto, le reservo el {día} a las {hora}. Para que el técnico llegue directo, ¿me comparte la ubicación...?"). Esto sigue el orden del equipo: en el 63 % de las citas reales la ubicación se pidió después de acordar la fecha. Si no, se llama a elegirPropuesta: la propuesta pasa a elegida y vence a las 4 h si es en horario, o a las 10:00 del siguiente día con atención. Se manda una nota mental a hasta 5 usuarios con permiso de logística o bot, con un aviso ⚠️ si la ubicación es aproximada. Con autoAprobar se llama a aprobarPropuesta. Al cliente le sale: "Perfecto, le confirmo en breve el espacio del {día} a las {hora}".

Manejo de errores del turno

  • conversacion_ajena (un vendedor tomó el chat mientras se esperaba al modelo; enviarMensajes relee el dueño justo antes del primer POST): pausa sin responder.
  • detalles.handoff que viene de wa-envio (plantilla pausada, payload inválido): handoff silencioso.
  • ia_sin_credito: se detecta con HTTP 400 y "credit balance" en la respuesta. apagarBot("sin_credito") una sola vez, nota a gerencia y todos los leads activos pasan a handoff pendiente. En la etapa de sombra hubo 170 turnos perdidos así.
  • Cualquier otro ia_*: handoff ia_no_disponible.
  • Firestore, Meta o red: la marca no avanza y el cron reintenta en 5 min. Al segundo fallo seguido, handoff error_repetido.
  • detalles.apagado (error 131048): todos los leads pasan a handoff pendiente.

Bot de ventas: tarifario y ejemplos

Tarifario: no está versionado en el repo. Se sembró con un script fuera del repo: 7 filas marcadas revisar: true para que gerencia las confirme. Campos de cada fila: nombre, plagas[], tipoInmueble[] (vacío = cualquiera), m2Max, precioLista, precioMinimo, pasoDescuento (5000 por defecto), duracionMin, servicioId (catálogo), valorAgregado, orden (el menor gana), textoCotizacion (con {precio}), activo, revisar. El panel valida que haya nombre, plagas, precio de lista, duración y servicio, y que el mínimo no supere al de lista.

Rangos orientativos, tomados de las pruebas:

  • Hogar completo (cucaracha, hormiga, insectos, araña, grillo; casa o apartamento hasta 125 m²): unos ₡40 mil de lista, con un piso unos ₡5 mil más abajo.
  • Local o negocio: unos ₡10 mil más.
  • Roedores con estaciones: ₡30 a 40 mil.
  • Pulgas y garrapatas: ₡50 a 55 mil.

elegirTarifa:

  1. Canoniza las plagas con regex. Por ejemplo, cucarach→cucaracha, ratón/rata→roedor, comején/termita→comejen, zancudo/mosquito→zancudo, abeja/panal→avispa.
  2. Filtra filas activas que compartan plaga.
  3. Con tipo de inmueble: filas cuyo tipo incluye el dicho o está vacío. Sin tipo y con varias filas: solo las que no distinguen inmueble.
  4. Con m² declarados: m2 <= m2Max.
  5. Ordena por orden.

Si no hay fila, la transición da handoff sin_tarifa.

rellenarPrecio formatea los colones a mano ("₡39.900", con punto de miles) porque toLocaleString depende de la ICU con la que corra Node. Además quita el símbolo duplicado: en sombra se vio "₡₡39.900" el 27-sep.

Ejemplos:

  • ejemplos-reales.json: 335 pares cliente/vendedor de conversaciones reales de julio a setiembre que terminaron en cita, anonimizados, con los montos reemplazados por {precio}. Campos: plaga, tipo, etapaReal, cliente, vendedor, fuente. Por tipo: agenda 115, ubicación 80, precio 71, objeción 38, calificación 31. Por plaga: cucaracha 48, roedor 48, comején 41, general 38, zompopa 28, pulga 26, zancudo 23, hormiga 22, avispa 20, chinche 14, murciélago 14, mosca 9, alacrán 4.
  • cargar-ejemplos.js: script manual con las opciones --dry-run, --borrar o carga. Primero borra los de la misma fuente y escribe en lotes de 400. Avisa si queda algún monto en el texto del vendedor. No lo corre ningún deploy ni ninguna función.
  • Aparte existen 96 ejemplos escritos a mano (la semilla está en test-bot-ventas/semilla-ejemplos.json: calificación 37, agenda 33, precio 26).

Uso en el turno: cargarEjemplos(plagaPrincipal) trae hasta 40 de esa plaga (o de "general"). ejemplosDelPaso filtra por el tipo que corresponde al paso (objecion→objecion, ubicacion→ubicacion, cotizado→precio, pasos de agenda→agenda, resto→calificacion), con respaldo objecion→precio y ubicacion→agenda. Se toman como máximo 5.

Bot de ventas: prompt y salida del modelo

No hay tool use. El modelo no llama funciones: devuelve un JSON con esquema estricto (SALIDA_SCHEMA, additionalProperties: false):

{ mensajes: string[] (1..3, se recorta a 3),
  datos: { plagas[], tipoInmueble (casa|apartamento|local|negocio|otro|null), m2, provincia, canton,
           distrito, referenciaUbicacion, nombreCliente, diasPreferidos[], horarioPreferido },
  intencion: enum,
  espacioElegido: { id, ambiguo, horaPedida },
  escalar: { si, motivo },          // señal interna, nunca se anuncia al cliente
  notaInterna: string }

No lleva siguientePaso a propósito, y datos no lleva montos. Las "acciones" las ejecuta el código según la intención y el paso.

armarSystem, en este orden:

  1. Identidad: "Sos {nombre}, asesora o asesor de ventas... atendés por WhatsApp a personas que escribieron desde un anuncio. Hoy es {día}".
  2. Estilo: trato de usted siempre; mensajes de 1 a 3 frases y hasta 3 seguidos; como mucho un emoji por mensaje; costarriqueñismos suaves ("con gusto", "viera que", "le podría dejar en", "quedo atenta/atento"); nunca listas con viñetas; no decir que es IA salvo que se lo pregunten directo, y ahí decir la verdad; nunca anunciar que pasa el caso a otra persona ni nombrar compañeros (se marca escalar y se sigue atendiendo); no prometer fecha ni hora antes de que el sistema entregue espacios; no inventar precios, garantías ni productos.
  3. Información de la empresa: producto de bajo impacto; niños y mascotas fuera del área de 30 a 45 min; formas de pago; el técnico avisa por WhatsApp cuando va en camino; duración de 45 a 60 min en hogares; reporte con fotos y factura electrónica. La política de promesas: es control y no erradicación; si el cliente no fumigó en los últimos 6 meses son 2 aplicaciones y la segunda se cobra como refuerzo; la revisión de 30 días existe solo al completar el plan; nunca escribir "garantía" (si la piden, se escala).
  4. LO_QUE_CIERRA: la guía que salió del corpus de 928 citas. Dar el precio claro y de una vez. Proponer fechas concretas apenas hay precio (94 % de los cierres las tuvieron). Pedir la ubicación después de que el cliente eligió fecha. Ante una objeción, empatía en una frase y una sola mejora. Si el cliente dice que lo piensa, una pregunta concreta y sin insistir dos veces. Cerrar repitiendo día, hora y qué incluye.
  5. Paso actual y datos ya conocidos (resumen sanitizado) más lo que falta.
  6. Acciones permitidas en este paso (accionesDelPaso). Por ejemplo: en nuevo, "el sistema ya mandó el saludo, NO saludes"; en calificación, "NO podés dar precio"; en cotizado, se le da el precio ya enviado y la lista de fechas con su id, y se le pide que ante una objeción responda con empatía sin monto.
  7. Hasta 5 ejemplos reales en formato "Cliente:/Nombre:", con la aclaración de que en {precio} el sistema pone el monto.
  8. promptExtra de la config y, si es una regeneración, la instrucción extra.
  9. Las reglas de la salida JSON.

El modelo nunca ve el precio de lista, el mínimo ni el paso de descuento. Solo ve precioOfrecido, para que no se pueda negociar con él.

El hilo (formatearHilo): últimos 30 mensajes después del último borrón. El cliente va como user con "[hh:mm]" adelante; todo saliente (del bot, de personas o plantillas) va como assistant. Los turnos seguidos del mismo rol se unen y el hilo arranca en user. Los medios se describen: [audio], [imagen], [ubicación: lat, lng], [Plantilla]. Todo texto del cliente pasa por sanitizeForPrompt (sin saltos de línea, recortado).

llamarModelo:

  • max_tokens 1600 y timeout de 25 s, que incluye la lectura del cuerpo.
  • 2 intentos (no 3, porque el presupuesto no alcanza). Solo se reintenta ante 429, 5xx o error de red, con espera de 4 s × intento.
  • Si el JSON llega cortado por max_tokens, se repite una vez con ×1,8 tokens y la orden "máximo 2 mensajes de 2 frases". En sombra se perdieron unos 80 turnos así.
  • Respuesta vacía: transitoria, salvo que el motivo sea refusal.
  • Antes de llamar exige que queden al menos timeout + 2 s de presupuesto.

Filtro de montos (filtrarMontos): primero quita del texto horas, "9 am", fechas "12 de octubre", fechas con barra, números de teléfono de 8 dígitos, años y medidas (m², minutos, días, cajas, años). Después reúne cifras de 1000 o más en las formas ₡X, "X mil", "39.900" y números de 4 dígitos o más. Cualquier cifra distinta del monto permitido en ese turno bloquea el envío.

Costo: se estima con una tabla de USD por millón de tokens (gama media: 3 de entrada y 15 de salida; liviano: 1 y 5). Se suma al lead, al log y a las métricas diarias.

Envío del lado servidor (wa-envio.js)

Es la versión de servidor de whatsappSend: no lleva token de empleado ni rate limit, pero escribe el mismo documento saliente con bot: true y actualiza la conversación (lastMessage*, lastMessageDirection: outbound).

  • Política de promesas: se revisan el cuerpo y los parámetros de la plantilla con la misma regla que en el resto del sistema. Si falla, se registra en promesas_bloqueadas y se lanza promesa_prohibida.
  • Idempotencia por docId:
  • bot_<wamid entrante>_<n>, con n como contador del turno guardado en sesion.seq. Sin el contador, la segunda tanda del mismo turno chocaría con la primera y se daría por repetida.
  • Fuera de turno se usan docBase fijos: bot_seg_<tel>_<intento>, bot_segp_..., bot_reof_<tel>_<n>, bot_cita_<propuesta> y bot_asig_....
  • Flujo: create() del documento con estado: "enviando" antes del POST. Si ya existe con wamid, devuelve repetido. Si existe en failed o unknown, repite el error sin volver a llamar a Meta. Si está en enviando desde hace menos de 2 min, devuelve en_curso; pasados los 2 min, reintenta.
  • El wamid se escribe inmediatamente después de la respuesta, sin nada en medio.
  • Errores y reintentos: timeout de 15 s y un reintento automático, solo si el fallo es transitorio con certeza: 429, 5xx, o una conexión que nunca abrió (ECONNREFUSED, ENOTFOUND, EAI_AGAIN). Un timeout o una conexión cortada a mitad de camino son inciertos: no se reintentan y el documento queda status: "unknown" y no failed. Si quedara failed, el trigger de estados lo trataría como no entregado y el bot repetiría la cotización.
  • Acciones según el código de error síncrono de Meta:
  • 131048 (tasa de spam): apaga el bot, deja nota a hasta 4 personas de gerencia y marca detalles.apagado.
  • 131056 (demasiados mensajes al mismo número): pausa ese teléfono 60 s, en memoria de la instancia.
  • 132015 y 132016: marcan la plantilla en plantillasPausadas y handoff plantilla_pausada.
  • 100 y 131009: guardan el payload en el log y handoff payload_invalido.
  • enviarPlantilla rechaza si la conversación tiene optOut. En el CRM, whatsappSend también responde 409.
  • Las listas interactivas se recortan a los límites de Meta: 10 filas, título de fila 24, descripción 72, botón 20, sección 24, cuerpo 1024. Los botones, a 3 con título de 20. En el CRM se guarda un body legible del tipo "Opciones: 1)...".
  • Las señales de leído y "escribiendo" se mandan con 5 s de timeout y sin reintento: son cosméticas.
  • bajarBytesMeta pide la metadata del media y descarga con el token: 15 s y 20 s.

Ubicación del cliente (geo.js)

  • Usa un mapa de distritos en JSON de unos 1,5 MB, con caja delimitadora y centroide por parte, y un JSON aparte con los nombres con tildes.
  • Límites de Costa Rica: latitud 8,0 a 11,2 y longitud −85,9 a −82,5.
  • Orden de confianza en resolverUbicacionCliente: 1. pin (type: location); 2. link (patrones @lat,lng, !3d!4d, q=/ll=, o un par suelto en el texto). Los links cortos se siguen con un GET de 8 s y, si la URL final no trae el punto, se busca solo con los patrones de link dentro del HTML; 3. señas a la tica → precisión referencia; 4. distrito por texto. Exige puntaje de 2 o más (al menos cantón) y que no haya empate → precisión distrito, en el centroide; 5. geocodificación libre → geocode; 6. señas sin geocodificar pero con distrito en el contexto → centroide del distrito.
  • Al final: distrito por polígono (con bbox y ray casting; si nada contiene el punto, el centroide más cercano a menos de 3 km con precisión "cercano") y cobertura del atlas. Si el atlas falla o está vacío, la respuesta es cubierto (sinAtlas): mandar todo a humano por un fallo de lectura sería peor.
  • buscarDistritoPorTexto: puntaje de distrito 3, cantón 2 y provincia 1, consumiendo las palabras en ese orden. Así "Tirrases de Curridabat" le da 5 a Tirrases. La cabecera de cantón suma 0,5 de bono. Hay alias como "Desampa" y "X centro". Devuelve como máximo 5.
  • Señas (interpretarReferencia, desplazar, geocodificarReferencia): se separan por tramos y se usa el tramo que trae el desplazamiento ("200 mts sur"), o el más largo con una palabra de referencia. Se quita el ruido (colores de la casa, saludos, conectores) y se descarta un landmark de menos de 4 letras. Se geocodifica "landmark, distrito, cantón, provincia" y se descartan los homónimos a más de 8 km del distrito. Desplazamiento: 111.320 m por grado de latitud, la longitud por el coseno, y las diagonales reparten con 1/√2. "Frente a", "contiguo" o "diagonal" son desplazamiento cero.
  • Geocodificador público: 1 llamada por segundo por instancia, con User-Agent propio, countrycodes=cr, 3 resultados y 8 s de timeout. Cualquier fallo devuelve una lista vacía.
  • Contexto del corpus: el 31 % de las citas reales se agendaron sin pin, solo con señas.

geo-cr.js no es del bot. Ubica una IP de visita web en provincia, cantón y distrito con una tabla mensual offline (rangos de IP a ciudad cruzados con polígonos de distrito, búsqueda binaria en v4 y v6, precisión "distrito" o "ciudad"; la IP nunca se guarda). Exporta ubicar e ipDe (el primer valor de x-forwarded-for). Al día de hoy ningún archivo de functions/ lo requiere: es parte de la "inteligencia del sitio".

Optimizador de agenda (optimus.js)

proponerEspacios(lat, lng, duracionMin, desde, hasta, radioKm=5, trasladoMin=30, maxResultados, evitarFechasDia, preferirDias, preferirHoraLocal, zonaId)

  • Por defecto busca desde mañana hasta 14 días adelante.
  • Lee las citas del rango (excluye Ruta 20, "Sin asignar" y canceladas) y las propuestas en estado elegida o aprobada, que cuentan como ocupación.
  • Candidatas: citas con coordenadas a 5 km o menos y a más de 10 m (a menos de 10 m casi siempre es el mismo inmueble). Para cada vecina se prueban dos posiciones: "antes" (inicio = vecina − duración − 30 min) y "después" (fin de la vecina + 30 min).
  • Se descarta la posición si: ya pasó, cae fuera de 07:00 a 20:00, cae en otro día operacional, choca con cualquier cita o propuesta del mismo día y ruta (con 30 min de traslado contra las demás), o toca un bloqueoHorario del ruteador.
  • Puntaje: arranca en 100. Resta min(40, 6 × días de distancia) y min(40, 8 × km). Suma 10 si el ruteador tiene técnicos, 10 si la zona del atlas coincide, 8 si es un día preferido y 12 si queda a ±60 min de la hora pedida. Resta 15 si es "antes" de las 8. El resultado se limita a 0..100.
  • Selección: primero uno por día distinto, después se completa con los mejores. Se ordenan por fecha. Cada espacio lleva la cita vecina (con coordenadas), el puntaje, un motivo legible ("A 1,2 km de otro servicio en X el ... a las ...") y el estado del ruteador.

validarEspacio: se llama justo antes de crear la cita. Relee el día completo de esa ruta y valida solape y bloqueos. Usa ignorarPropuestaId para que la propuesta no choque consigo misma. La ventana de 07:00 a 20:00 aquí no se aplica.

crearCita: exige teléfono de 11 dígitos con prefijo país, inicio válido y coordenadas. Arma la cita con la misma forma que la cita manual de la agenda: fecha_dia, fecha_mes, metadata.ruta, estado: "agendada", monto, servicios[], detalle_cotizacion (sin él, el modal de edición recalcula el monto), plagas, direccion con links de navegación, coordenadas, otras_senas, ubicacion_precision, ids de ubicación, observaciones, turno: "Turno 1", canal y modalidad_cita_creada: "bot_optimus". Hace upsert en clientes/{phone} sin pisar un nombre existente con vacío y con totalCitas +1.

Contexto: según el corpus, solo el 12 % de las citas humanas se hacían con Optimus.

Bot de ventas: handoff a humano

  • handoff(lead, motivo): marca la conversación con botHandoff: true, el motivo y un resumen de 3 líneas (plaga e inmueble; zona y cobertura; precio ofrecido o aceptado, paso y motivo). Elige vendedor e intenta asignar.
  • En horario: usuarios con work_sessions.working y latido en presence_live de hace menos de 20 min. Fuera de horario: la lista usuarios del override.
  • Gana el de menos asignaciones en las últimas 24 h según el libro de asignaciones, que se poda en la misma transacción.
  • asignarAVendedor es una transacción que falla en silencio si el chat ya no es del bot. Escribe assignedTo, botActivo: false y botHandoff: true, suma al libro de asignaciones y deja el lead inactivo.
  • Sin vendedor disponible: el lead queda activo: true y handoffPendiente: true. El bot sigue contestando dudas generales (en handoff no da precios ni fechas) hasta que el cron lo reparte.
  • Fuera de horario: nota mental a los primeros 4 de la lista del override.
  • Texto al cliente: no se anuncia ningún traspaso ("Permítame un momento para revisarlo bien y le confirmo"). Fuera de horario: "Le confirmo a partir de las {próxima hora hábil}". Es silencioso en los casos tomado, no_entregable y autorespuesta.
  • Sombra: solo se marca en el lead y se loguea el texto que habría salido.
  • apagarBot: pone activo: false y apagadoPor, avisa a hasta 4 personas de gerencia y pasa a handoffPendiente hasta 200 leads activos que no sean de sombra.

Bot de ventas: cron botVentasSeguimientos (cada 15 min, hora de Costa Rica, 300 s, presupuesto de 240 s)

  1. vencerPropuestas (corre aunque el bot esté apagado). Hasta 30 propuestas elegida con expiraAt vencido pasan a expirada. Si el lead sigue en propuesta con esa misma propuesta: reofertar ("Ese espacio se ocupó. Le propongo estos:").
  2. asignarHandoffsPendientes (también corre con el bot apagado). Hasta 50 leads. Si no hay vendedor disponible, corta la vuelta. Si la ventana de 24 h está abierta, le avisa al cliente.
  3. Si el bot está apagado o en sombra, termina aquí.
  4. Seguimientos: hasta 20 leads activos con seguimiento.proximoAt vencido. - Se omiten los de sombra, los pausados, los que tienen handoff pendiente y los pasos sin seguimiento. Si la conversación tiene opt-out o está pausada, se limpia el seguimiento. Si no es del bot, pausa. - Recuperación: si el cliente escribió después del último saliente y el turno no quedó registrado, se atiende desde el cron (atenderLead sin esperar el lock, solo si quedan más de 120 s). - Con intentos >= 3: cerrado_sin_respuesta. - Ventana cerrada: si la plantilla está pausada, handoff. Si ya se mandó 1 plantilla o la plantilla está bloqueada por 131049 o 130472 (7 días), se cierra. Como máximo 1 plantilla por lead. - reclamarSeguimiento en transacción ANTES de enviar: verifica que intentos no haya cambiado, suma 1 y programa el próximo con horas[min(n, len−1)]. Así, si la corrida muere entre el envío y la escritura, la siguiente no manda una segunda plantilla. - Ventana abierta: el modelo escribe 1 mensaje (300 tokens, 15 s, 1 intento, sin montos ni fechas). Si falla o el filtro de montos lo rechaza, sale el texto fijo "¿Sigue interesado en el servicio? Con gusto le ayudo a coordinar 🙏". - Con plantilla se marcan raspadoAt, raspadoPor y raspadoPlantilla en la conversación. - Si un lead da error, su seguimiento se corre 1 hora para no bloquear al resto.

Horas: el primer seguimiento se programa al cerrar cada turno, a 2 h del último saliente; los siguientes, a 20 h y a 48 h.

Bot de ventas: estados de entrega (botVentasOnStatus)

Solo actúa cuando un mensaje del bot pasa a failed:

  • 131047 (ventana cerrada): ventanaCerrada: true. Si era un seguimiento, se reprograma para ya.
  • 131026 (no entregable): handoff silencioso.
  • 131049 / 130472: plantillaBloqueadaHasta = ahora + 7 días.
  • 131050: opt-out.
  • Cualquier otro: ultimoTurnoNoEntregado: true (el siguiente turno repite el precio o la lista).

Bot de ventas: endpoints HTTP

  • botVentasAdmin (POST, 60 s, cuerpo {accion, params}). Lo usan quien tenga bot_ventas, logística (solo para aprobar o rechazar) y gerencia (que se consulta al final porque gasta más lecturas). Un ErrorBot responde 400 con su código; cualquier otro error, 500.
  • aprobarPropuesta. Exige canalId. Hace compare-and-set de elegida a aprobando en una transacción, así el segundo aprobador se encuentra "aprobando" y se va. Valida el espacio; si está ocupado, pasa a rechazada_ocupado y se reoferta. Si está libre crea la cita, con un aviso ⚠️ en observaciones cuando la ubicación es aproximada. Si la creación falla, vuelve a elegida con ultimoError. Al cerrar pasa a convertida y el lead a cita_creada. Aviso al cliente: texto si la ventana está abierta; si no, plantilla de confirmación (como máximo 1 por lead). Si el aviso falla, handoff aviso_cita_fallido.
  • rechazarPropuesta (reoferta evitando ese día), pausar, reanudar (a pasoAnterior), tomarLead (pasa a quien llama y suma al libro), setConfig, reofertar y resumen (conteos por paso y por brazo, propuestas pendientes y config).
  • previewVentasBot (GET o POST con phone, solo gerencia, 120 s). Genera lo que el bot contestaría sin enviar, sin lock y sin escribir el lead. Si no hay lead, arma uno mínimo con el brazo por paridad. Audita en bot_ventas_previews quién la pidió, los mensajes, la intención, el paso y el costo. Se usa desde el botón de la vista de conversación del embudo del vendedor, que además permite copiar el texto a la caja.
  • asignarLeadBot (POST, solo gerencia, 60 s). {phone, brazo} asigna; {phone, soltar: true, destino} devuelve el chat a una persona. Botones en el embudo del vendedor.

revisarJustificacionSinOptimus (POST, empleado, como máximo 5 instancias). Un modelo de gama alta con esfuerzo bajo juzga si una justificación de 100 a 4000 caracteres para agendar sin el optimizador es una restricción real. Devuelve {veredicto: valida|debil|invalida, motivo, sugerencia}, en segunda persona y con tono de compañero. Los errores de saldo de IA se responden con el helper común. Hoy ninguna página lo llama: desde el 29-oct el arrastre en la agenda lleva a Compás y, hasta el 30-nov, ofrece una "excepción ruta y hora" con un motivo de 20 caracteres o más, que se guarda en metadata.justificacion_sin_optimus con modalidad excepcion_ruta_hora y sin revisión por IA.

Bot de ventas: pantallas

bot_ventas.html (permiso bot_ventas, que hereda de reportes). Tiene un indicador de estado (encendido y modo, sombra, "apagado por X") y seis pestañas:

  • Propuestas: en vivo, las que están en elegida. Cada tarjeta muestra cliente, brazo, cuánto falta para que venza, espacio, motivo de Optimus, aviso naranja si la ubicación es aproximada, precio aceptado, tarifa, puntaje y ruteador. Botones: aprobar, rechazar (con motivo que el cliente no ve) y ver chat.
  • Leads: los activos en vivo, más un historial de 200 por updatedAt. Filtros por paso, brazo y texto. Acciones: pausar, reanudar y tomar.
  • Conversación: el hilo de WhatsApp (200 mensajes) intercalado con el log de turnos (60): intención, escalamiento, nota interna, "habría dicho" en sombra, error, costo y latencia. Enlace al embudo.
  • Tarifario: solo gerencia edita. La regla mira el departamento o cargo, no los permisos; por eso la pantalla decide por la ficha.
  • Configuración: el formulario completo; el interruptor guarda por separado.
  • Métricas: por brazo desde los leads (cotizados, aceptaron, citas, handoff, opt-out, sin respuesta, mensajes, USD, primera respuesta promedio) y por día desde bot_ventas_metricas (los últimos 30, ordenados en el cliente).

agenda-fab-bot.js: botón flotante con el conteo de propuestas elegidas y bloques tentativos punteados en el cronograma para las elegidas o aprobadas, repintados con un MutationObserver. No escribe nada.

reporte_bot.html no tiene que ver con estos bots. Es el visor de conversaciones del chatbot web del sitio de captación (otro proyecto de Firebase, colección bot/{id}/mensajes, con roles user/assistant). Marca como "Lead" la conversación en la que el usuario escribió un teléfono de 8 dígitos. Tiene búsqueda y navegación con teclado.

reporte_optimus_vendedores.html: muestra qué porcentaje de las ventas de cada vendedor activo de Ventas se agendó con el optimizador (modalidad_cita_creada == "optimizador_algoritmo"), por mes, en monto o en cantidad. Las citas con varios vendedores se acreditan completas a cada uno y el total se calcula aparte. Avisa la cobertura, porque el campo existe desde abril de 2026. Las citas del bot (bot_optimus) no cuentan como optimizador en este reporte.

Bot de ventas: pruebas automatizadas

Corren con Node sin Firestore, sin Meta y sin el modelo: usan una base en memoria (base-falsa.js), un fetch falso y dobles inyectados con _inyectarDeps. Se corren con node functions/test-bot-ventas/<archivo>.test.js, uno por uno.

  • bot-ventas.test.js: elegirTarifa (6 combinaciones), filtrarMontos, decidirBrazo, horario con grilla real, rechazos de elegibilidad, transiciones, que el system solo traiga el precio ofrecido, un turno en sombra (no envía, sí loguea), un turno real con docId determinista y cotización en el segundo turno, pausa por saliente humano y opt-out por botón sin IA.
  • correcciones.test.js: sin referral no reclama y soloPauta no manda; dos tandas del mismo turno sin choque de docId; elección por modelo crea la propuesta y avisa a logística; aprobación con canal obligatorio y bloqueo del segundo aprobador; entrantes del mismo segundo; fallo de red (+5 min y luego handoff); ultimoTurnoNoEntregado; descarte de cambios con lock ajeno; seguimiento reclamado antes de enviar; plantilla pausada a handoff; apagado masivo a handoff pendiente; zonaId hasta Optimus.
  • venta-real.test.js: fechas con el precio; pin lejos (reoferta) o cerca (propuesta exacta); aceptar sin elegir (repite opciones); JSON cortado y sin crédito; apagado por falta de crédito; prompt en paso de ubicación; muestreo en sombra; un solo símbolo de colón.
  • asignacion-manual.test.js: asignar y soltar; el bot contesta aunque la config esté apagada; conserva lo que sabía; rechaza opt-out; contesta de una o no interrumpe; borrón con puntos suspensivos; no anuncia traspaso; horas en formato de 12 h.
  • wa-envio.test.js: idempotencia en sus 4 estados, clasificación de errores (4xx, 5xx, ENOTFOUND, ECONNRESET, timeout), señales sin reintento, 131048, 131056, 132015, 100, opt-out en plantilla, recortes de listas y botones, descarga de media.
  • geo.test.js: formatos de link, ubicación por polígono y costa, texto a distrito (Tirrases, Desampa, alias), haversine, atlas (dos formatos de polígono, caché de 10 min, fallo que no bloquea), geocodificador (URL, User-Agent, 1 s entre llamadas), links cortos, señas y homónimos lejanos.
  • optimus.test.js: día operacional (04:59 contra 05:00, cambio de mes y año), sin solapes, ventana de 07:00 a 20:00, propuesta como ocupación, bloqueos, días distintos, puntaje y preferencias, validación y cita simulada (con 23:30 y 04:30 local).

Simulador de campo

Propósito: práctica hablada en la que la IA hace de cliente difícil y el técnico lo atiende por voz, sin opciones para marcar. Se usa el permiso de capacitación y se entra desde la página de capacitación.

Escenarios (ESCENARIOS). Cada uno tiene título, banco de origen, voz, cliente, primer mensaje, situación, acierto y error esperado. Cada escenario usa una voz tica distinta.

  1. cloro-cocina: un chef lavó la cocina con cloro 10 min antes. El acierto es explicar que el cloro repele y arruina el atrayente del gel, esperar al menos 30 min y reprogramar la limpieza para la noche anterior.
  2. gel-intacto: dueño molesto en el tercer tratamiento, con el gel sin consumir. El acierto es preguntar si se consumió, reconocer la aversión al atrayente dulce y cambiar a un gel no glucosado rotando el grupo químico.
  3. bebe-mascotas: bebé, gatos persas y un acuario que la clienta no menciona. El acierto es descartar la permetrina por los gatos, dar tiempos de salida y regreso, preguntar por otros animales y, si aparece el acuario, cubrirlo y apagar filtros y bombas.
  4. regateo-apuro: administradora comercial que propone recortes y apura. El acierto es sostener lo innegociable explicando qué se pierde y ofrecer alternativas reales.

Prompt del personaje: "Sos el cliente, NO un asistente ni un profesor". Español de Costa Rica hablado, frases cortas, sin listas. No sabe nada técnico. No le da la respuesta ni guía al técnico, no sale del personaje aunque le pregunten si es IA, no inventa datos y no corrige un error: reacciona como reaccionaría su personaje. El acierto y el error van como contexto oculto.

Endpoint simuladorIniciar (POST, cualquier empleado):

  1. Cupo de 2 por día por usuario, contado en el servidor sobre simulador_inicios del día en Costa Rica (MAX_POR_DIA). Si se pasa, responde 429. Un inicio abandonado igual consume el cupo, porque gasta minutos.
  2. agenteDelEscenario: el id del agente se guarda en config_simulador/{escenario}. Si existe, se hace PATCH en cada arranque, así un cambio de prompt en el código se aplica solo. Si el PATCH falla porque lo borraron, se recrea. Config del agente: prompt, primer mensaje, idioma "es", voz, turn_timeout de 20 y max_duration_seconds igual a 6 min (MAX_MINUTOS). El LLM interno del proveedor de voz es configurable.
  3. Registra el inicio (username, escenario, agentId, dia, creadoMs, consumido: false) antes de pedir la URL.
  4. Devuelve una URL firmada de un solo uso. La clave del proveedor nunca llega al navegador.

Endpoint simuladorCalificar (POST con {escenario, conversationId, segundos, nombreCompleto}):

  1. El conversationId tiene que cumplir el formato [A-Za-z0-9_-]{6,80}.
  2. Tiene que existir un inicio sin consumir de este usuario y este escenario, de las últimas 3 h.
  3. La transcripción se baja del proveedor y nunca se acepta del navegador. Se intenta hasta 6 veces, cada 2 s, porque tarda en estar lista.
  4. El agente de la conversación tiene que ser el del inicio, y la conversación tiene que haber empezado después del inicio, con 2 min de margen.
  5. Se califica una sola vez: un batch hace create en simulador_conversaciones/{convId} y marca el inicio como consumido. Si choca, responde 409.
  6. Transcripción en formato CLIENTE:/TÉCNICO:, descartando líneas de menos de 11 caracteres. Se cuentan los turnos del técnico.
  7. Un modelo de lenguaje de gama media (1200 tokens) actúa de instructor y califica de 0 a 100 en diagnóstico, seguridad, trato y cierre. No castiga muletillas. Una conversación muy corta lleva notas bajas. Un error de seguridad grave manda seguridad por debajo de 40. Devuelve JSON con resumen, acertos[] y fallos[], y se le quitan las cercas de bloque de código antes de parsear.
  8. Aprobación: promedio de 80 o más y seguridad de 60 o más (la seguridad no se compensa con los otros ejes).
  9. Escribe en quizzes_entrenamiento con el formato del motor de quizzes, para que entre solo al acta y al expediente imprimible: quizId: simulador-<id>, título, porcentaje, correctas (ejes de 80 o más), total 4, aprobado, esSimulador, simuladorDia, segundos, id de conversación, turnos del técnico, notas por eje, resumen, aciertos y fallos (hasta 8 de cada uno), transcripción (hasta 20.000 caracteres), detalle[] con una fila por eje e intento (los previos más 1).

Reglas de Firestore: quizzes_entrenamiento tiene un bloque propio. update y delete están cerrados, y create rechaza esSimulador/simuladorDia, para que nadie se fabrique un aprobado del simulador desde el navegador. Se probó con el emulador en 11 casos.

Página:

  • El SDK de voz se carga desde un CDN con una versión fija, como bundle ESM y con import() dinámico. Lección: la ruta del módulo de esa versión no existe, y con un import estático el 404 tumbaba la página entera y el cupo quedaba colgado en "Revisando...".
  • Orden al arrancar: SDK → permiso de micrófono → simuladorIniciar → startSession con la URL firmada. Si falla el SDK o el micrófono, no se gasta cupo.
  • Reloj con alerta a 90 s y estado crítico a 30 s del final; corta solo a los 6 min.
  • Transcripción en vivo por turno.
  • Al colgar: endSession, luego calificar y mostrar el resultado.
  • beforeunload cierra la sesión.

Casos borde y lecciones:

  • El proveedor de voz rechaza para agentes las voces con "live moderation". No se ve en el detalle de la voz: solo se sabe al intentar crear el agente. El código traduce ese error a un mensaje legible.
  • El Chromium de pruebas automáticas no abre WebSockets, así que la conversación hablada no se puede probar ahí. Sí se pueden probar las tarjetas, el cupo, el SDK y la creación del agente.
  • La clave del proveedor no tiene permiso de lectura de usuario, así que no se puede consultar el crédito por API.

Bot de ventas y simulador: hallazgos en el código

  1. Cupo del simulador desalineado. La página calcula lo que queda contando quizzes_entrenamiento con simuladorDia de hoy (las calificadas). El servidor cuenta simulador_inicios (los iniciados). Un inicio abandonado consume cupo en el servidor pero la página todavía muestra que queda.
  2. Aprobar con espacio ocupado. El backend responde {ok: false, motivo: "espacio_ocupado", reoferta}. La página espera r.estado === 'rechazada_ocupado', pero llamarAdmin lanza un error antes por ok: false, así que el usuario ve el aviso de error "espacio_ocupado" en vez del mensaje amable. La reoferta igual se hace.
  3. soloPauta. La pantalla lo muestra y lo manda, pero setConfig lo descarta: el referral es obligatorio siempre.
  4. transicion compara contra la intención "confirma" en cita_creada, que no existe entre las intenciones. No tiene efecto, porque ese paso se resuelve antes por regex o con handoff.
  5. revisarJustificacionSinOptimus quedó sin uso desde que la agenda pasó a Compás más la excepción con motivo libre.
  6. Las citas del bot llevan la modalidad bot_optimus y no suman en el reporte de uso de Optimus por vendedor.
  7. Estado operativo conocido (al 26-sep): el bot en sombra con activo: false; las fichas de los bots inactivas (hay que activarlas al encender para que cuenten en los reportes); pendiente de recargar crédito de IA; y decisiones de gerencia abiertas (tarifario con lista y piso, sobre todo comején y zompopas; política de identidad; comisión de las citas del bot; 3 plantillas utility; zonas del atlas como cobertura; tope en USD). No pude verificar el estado actual en Firestore porque la tarea era de solo lectura sobre el código.

WhatsApp de empresa (teléfonos de los vendedores sin la API oficial)

Propósito y problema

Además de la línea oficial (la "Central", conectada por la API de WhatsApp Cloud), cada vendedor tiene un teléfono de empresa con WhatsApp Business. Antes, lo que pasaba en esos teléfonos era invisible para gerencia: no se podía auditar, medir leads ni mandar avisos automáticos desde ahí. El módulo conecta esos teléfonos como "dispositivo vinculado" (WhatsApp Web) desde un servidor propio, sube chats y mensajes a Firestore y permite responder desde el sitio. Sirve también como el canal de salida de todos los avisos automáticos internos (un número robot, "EcoBot", que escribe a grupos).

Usuarios: gerencia (ve todos los teléfonos salvo los privados), cada vendedor (solo el teléfono que tiene asignado), y procesos de servidor (encolan mensajes).

Arquitectura

Servidor propio (VPS Linux, 8 GB RAM, Xvfb)
  └─ agente.js (Node 20+, whatsapp-web.js + Chrome real, una ventana por número)
       ├─ lee cuentas.json (lista de números, nombre, soloDe, grupos)
       ├─ escribe wa_equipo_cuentas/{numero}            estado, qr, latido
       ├─ escribe .../chats/{chatId}                     resumen del chat
       ├─ escribe .../chats/{chatId}/mensajes/{msgId}    cada mensaje
       ├─ sube media a Storage wa_equipo/<numero>/...
       └─ escucha wa_equipo_salida (cola) y manda
Firestore  <── whatsapp_empresa.html (lee chats, escribe en la cola)
           <── Cloud Functions (encolan avisos en wa_equipo_salida)

Servicio del sistema operativo con reinicio automático, un temporizador "vigía" que verifica el latido cada 10 minutos y un reinicio nocturno programado a las 3:30 a. m. Con 5 números conectados el servidor usa unos 6 GB; cada número es una ventana de Chrome. Datos locales fuera del repo: cuentas.json, sesiones/ (perfil de Chrome por número), estado.json (hasta dónde se sincronizó cada chat), agente.log, latido.txt.

Riesgo explícito en la documentación del agente: envíos masivos o automáticos a clientes pueden hacer que WhatsApp bloquee el número. Los automáticos van a grupos internos, no a clientes.

Colecciones

Colección Id Campos clave
wa_equipo_cuentas número (506 + 8 dígitos) estado (iniciando, qr, cargando, conectado, desconectado, error, apagado), qr (dataURL), qrEn, progreso, latido, perfil, plataforma, orden, asignado (username, lo escribe un admin a mano, el agente no lo toca), soloDe ("" público, username = privado, "gerencia"), grupos (bool)
wa_equipo_cuentas/{n}/chats id de chat (@c.us, @lid o @g.us) nombre, telefono (real, porque los ids @lid no sirven para buscar), esGrupo, archivado, noLeidos, ts, ultimo{texto(160), deMi, tipo, ack}, foto, origen (primer mensaje real de la conversación, completo bool)
.../chats/{id}/mensajes id corto del mensaje wid, deMi, autor, texto, tipo, ts, ack, tieneMedia, media{ruta,url,mime}, nombreArchivo, reenviado, ubicacion{lat,lng,nombre}, citado{texto,id,autorTel}, autorTel, menciones[], reacciones[{emoji,de,yo,t}], enviadoPor, historico
wa_equipo_salida auto cola de salida (ver abajo)
wa_equipo_accesos auto auditoría: cada chat que alguien abre {usuario, numero, chatId, chatNombre, chatTelefono, cuando}; nadie edita ni borra
wa_equipo_config/* docs fijos ocultos.telefonos[] (chats de gerencia que no se suben), grupos.ids (ids de grupos de coordinación hallados), etiquetas.porTelefono (mapa teléfono a lid para menciones), bot.numero (qué número es el robot de avisos; interruptor único), comunidad.grupos[] (grupo personal de cada empleado), desconectados.activo, leads.desde, garantias.chatId, ventas_auto
wa_equipo_leads, wa_equipo_leads_dia ver más abajo medición de leads
wa_desconexion/{numero} número abierta, desde, ultimoAviso, avisos, estado, volvioEn

Cola de salida wa_equipo_salida

Formato de un pedido:

Campo Uso
numero desde qué teléfono sale
chatId o telefono destino (si es teléfono de 8 dígitos se antepone 506 y se resuelve con getNumberId)
tipo texto, imagen (solo servidor, imagen ya en Storage), archivo (desde el sitio), media (pedir que se descargue un adjunto viejo), reaccion
texto cuerpo o pie de foto
ruta, mime, nombre, url adjunto en Storage
comoDocumento, hd forzar documento, foto sin compresión
menciones[] teléfonos a etiquetar; el texto lleva @XXXXXXXX donde va cada una
citarWid, citarTexto responder citando un mensaje
wid, emoji para reacción ('' la quita)
estado pendiente, enviando, enviado, error
creadoPor, creadoEn, tomadoEn, hechoEn, error, msgId rastro
al recibir doc "pendiente" para mi número:
  transacción: si sigue "pendiente" → "enviando" (evita doble envío con dos agentes o tras reinicio)
  si número no conectado → error
  si tipo == media: buscar mensaje, descargar y descifrar, subir, guardar media en el doc del mensaje
  si tipo == reaccion: enviar reacción; si la librería falla, consultar reacciones y confirmar
  resolver chatId (teléfono → wid)
  convertir cada mención a "lid" (WhatsApp ya etiqueta por lid; con el teléfono sale como texto suelto)
  opciones: sin marcar leído; foto/video normal; audio como nota de voz (el texto va aparte después);
            otros mime como documento; citar si hay citarWid
  intentar enviar hasta 4 veces si falla con "chat not found" (chat nuevo sin lid todavía), espera 3 s × intento
  si la librería devuelve undefined, buscar el mensaje recién salido en el chat
  si no aparece → error "WhatsApp no confirmó el envío. Revisá el chat antes de reintentar."
  marcar enviado + guardar el mensaje en la subcolección con enviadoPor

Lecciones del agente (código comentado)

  • La librería quedó incompatible con la versión de WhatsApp Web: chats con id @lid hacen fallar getChats() y similares. El agente lee chats y mensajes directo de las colecciones internas de la página y arma los objetos a mano.
  • Los eventos de mensaje nuevo y de acuse no llegan siempre: se sondea la página cada 3 s; un repaso de chats cada 2 min; reacciones cada 30 s (no quedan en el modelo, hay que consultarlas mensaje por mensaje dentro de las últimas 24 h).
  • El evento ready puede no llegar al reabrir sesión: un vigía a los 60 s arranca igual si la página ya tiene chats.
  • WhatsApp Web se recarga solo cuando sale versión nueva y la ventana queda muerta. Desde que pasó (un teléfono quedó una hora y media sin leerse), 30 s de sondeo fallido o 3 latidos sin estado reinician la ventana.
  • Medios: se descargan del CDN de WhatsApp y se descifran en Node (HKDF con la etiqueta de tipo de medio), porque el método de la librería rechaza ciertos mime. Límite 25 MB; al arrancar se bajan solos los de los últimos 7 días; los viejos se piden con el botón "Traer" (cola tipo: media).
  • Al conectar se suben los 60 chats más recientes con sus últimos 40 mensajes; después, todo en vivo. WhatsApp Web solo tiene unos 24 chats en memoria al abrir, por eso el historial viejo de estos teléfonos es incompleto.
  • No marca leído ni se pone "en línea": el empleado sigue viendo sus no leídos en el celular.
  • El teléfono tiene que conectarse a internet al menos cada 14 días o WhatsApp cierra la sesión vinculada.
  • Si se escanea el QR con un teléfono distinto al de la cuenta, el agente lo rechaza con error.
  • Envío de imágenes: la librería revienta con "Data passed to getter must include an id property"; se envuelve su función de procesar media para devolver datos planos sin id.
  • Fotos de perfil: el enlace firmado vence; se descarga y se sube a Storage, se repite cada 6 h solo si cambió.

Origen de cada chat (para medir leads)

Al ver un chat por primera vez, el agente carga hasta 5 tandas de historial hacia atrás y guarda una sola vez origen con el primer mensaje real y completo = true si WhatsApp confirma que no hay más viejos. Sin esto, un cliente viejo que vuelve a escribir parecería lead nuevo.

Página whatsapp_empresa.html + whatsapp_empresa.js

  • Riel de tarjetas, una por teléfono, con estado y QR si hace falta vincular. La Central (línea oficial) aparece como una tarjeta más solo para quien ve todos: lee whatsapp_conversations y whatsapp_messages y responde vía la función whatsappSend; fuera de la ventana de 24 h la caja queda deshabilitada (hay que mandar plantilla desde el embudo).
  • Franja fija "Módulo monitoreado" y registro en wa_equipo_accesos por cada chat abierto.
  • Buscador global de teléfono: busca en el campo telefono de los chats de cada número y en la Central; uno solo lo abre, varios los lista.
  • Chats de clientes en verde (teléfono con facturación previa o en el espejo de cartera) con el nombre del vendedor de la última cita; chats internos (números de empleados y de la empresa) en azul, ocultos para quien no es el asignado ni gerencia (filtro solo de pantalla).
  • Adjuntos: clip, pegar o arrastrar, hasta 10 archivos y 60 MB; fotos mayores a 1,5 MB se achican a JPEG de 1920 px antes de subir a wa_equipo/<numero>/salida/.
  • Responder citando (flecha en cada burbuja) y reacciones (6 emojis).
  • Parámetros de URL para abrir directo: ?tel=&num=&texto= (los usan garantías y tickets en rojo).

Permisos y privacidad

Caso Regla
Gerencia clave whatsapp_empresa, hereda de reportes_gerenciales
Vendedor clave whatsapp_empresa_propio (sin herencia) + asignado == su username; solo lee la cola que creó
Número privado (soloDe == usuario) solo su dueño, ni gerencia
Número robot (soloDe == 'gerencia') toda gerencia, nadie de ventas
Grupos de coordinación solo el asignado del teléfono los lee y escribe; los demás listan con esGrupo == false
Chats de gerentes lista ocultos; el agente no los sube

Trampa documentada: en la regla hay que leer cuenta.soloDe directo; con un valor por defecto en get('soloDe',''), una lista sin filtro traía el número privado a cualquier gerente. Las reglas se prueban en el emulador (decenas de casos por cambio).

Aviso de teléfono desconectado (functions/wa-desconectados.js)

waDesconectadosAviso, cada 15 min de 6 a 22 h.

si config.activo == false → nada
si el número robot está caído → nada (no hay por dónde avisar)
para cada cuenta (menos robot y privadas):
  caído = estado != "conectado" o latido > 10 min
  si volvió: cerrar registro wa_desconexion (abierta=false)
  si caído:
    desde = último latido si ya está viejo, si no ahora
    esperar 10 min desde "desde" antes del primer aviso (absorbe reinicios del agente)
    repetir cada 15 min
    solo si el asignado está en su horario (rol de la semana en roles_ventas)
    encolar texto al grupo personal del asignado con @mención
      (si estado == qr: instrucciones para reescanear; si no: revisar internet del celular)

Leads en teléfonos de empresa (functions/wa-equipo-leads.js)

waEquipoLeadsRecientes cada 30 min y waEquipoLeadsNow (gerencia). Lead nuevo si:

  1. la conversación la empezó el cliente (origen), en o después de wa_equipo_config/leads.desde;
  2. origen.completo;
  3. no es número interno (empleados, ocultos, otro teléfono de empresa);
  4. no compró antes (cita con monto, sin Ruta 20 ni cancelada, previa al primer mensaje, o está en cartera_telefonos);
  5. no busca empleo (regex común: "vacante", "currículum", "hoja de vida", "empleo", "reclutamiento").

Lo que no cumple queda con estado: excluido y motivo (cliente, inicio_vendedor, interno, sin_confirmar, empleo). Venta = misma regla del CRM (cita con monto, sin Ruta 20, que empieza y se creó después del primer mensaje), buscada durante 60 días. Sin respuesta en 24 h = sin respuesta. Guarda también si el cliente escribió a la Central antes o después.

Salida: wa_equipo_leads/{numero}_{tel} (detalle, solo permisos de WhatsApp empresa) y wa_equipo_leads_dia/{día} con datos en JSON compacto [numero, asignado, estado, motivo, venta, monto, minutosRespuesta, central, sinRespuesta], sin teléfono ni texto, que consume la página de conversión por vendedor.

Hallazgo de la primera lectura: la mayoría de conversaciones de esos teléfonos las empieza el vendedor (los usan para salir a buscar, no para recibir).

WhatsApp de coordinación por cita (functions/wa-coordinacion-citas.js)

waCoordinacionCitasHoraria (minuto 20 de cada hora, citas de hoy a +7 días) y waCoordinacionCitasNow (backfill). Escribe en citas/{id}.whatsapp_coordinacion por cuál WhatsApp nuestro se habla con ese cliente. Regla: sin evidencia no se escribe ni se borra.

Prioridad: vendedor_cita (teléfono de empresa cuyo asignado está entre los vendedores de la cita) > crm (Central) > otro_equipo; dentro del mismo escalón gana el chat más reciente. Campos: linea, canal (equipo|crm), asignado, regla, telefonoCliente, chatId, crmAssignedTo, ultimoMensajeAt, candidatos[], actualizadoAt, version. Es campo de primer nivel para que sobreviva a las ediciones de la agenda y no se clona al clonar una cita. Primer backfill: 85 % de las citas quedaron mapeadas.

Respuestas en coordinación (functions/coordinacion-respuestas.js + respuestas_coordinacion.html)

Regla de negocio: toda etiqueta (@) que pone un técnico en el grupo de coordinación de ruta se responde lo antes posible, mínimo con "Recibido", usando Responder sobre ese mensaje. Solo así cuenta.

coordinacionRespuestas cada 15 min, recalcula hoy y ayer.

etiqueta = mensaje de un empleado del departamento Fumigación con ≥1 mención
varias etiquetas del mismo técnico en 2 min = un solo pedido
respuesta = primer mensaje de OTRA persona con citado.id == id de la etiqueta
"suelta" = el etiquetado escribió en los 30 min siguientes sin citar (no cuenta)
sin respuesta y < 60 min = "esperando"

Escribe coordinacion_respuestas/{fecha}: etiquetas, respondidas, sinRespuesta, sueltas, pct, dentro15, mediana, promedio, porEtiquetado, porRespondedor, porTecnico, detalle[]. Página con permiso respuestas_coordinacion (hereda de reportes gerenciales); consulta por rango de documentId sin orderBy para no pedir índice. Línea base de la primera semana: 28 % respondidas con Responder.

Avisos automáticos por el robot

Todos los avisos internos salen del número robot (wa_equipo_config/bot.numero) encolando en wa_equipo_salida, con formato fijo: cabecera "AVISO AUTOMÁTICO", cuerpo, y pie "Si tenés dudas o creés que es un error, comunicáselo a Gerencia". Destinos: grupo personal de cada empleado (mapa comunidad), grupo de ventas, grupo de garantías, grupo de alertas. Cada aviso tiene su interruptor activo:false en un doc de config para apagarlo sin desplegar, y un candado por día o por evento para no repetir.

Clientes: analítica, cartera y marcado de cliente

Marcado instantáneo de "es cliente" (stampClienteOnCita + reconcileClientesScheduled)

Problema: el CRM necesita saber al instante si un teléfono ya compró (para pintar el chat, sacarlo de la columna de prospectos y no contarlo como lead nuevo), sin leer todas las citas cada vez.

Dos capas:

Capa Trigger Qué hace
A, instantánea onDocumentWritten citas/{id} calcula delta de monto y de cantidad de citas por teléfono y lo suma con increment en analitica_clientes/{tel} (total_facturado, total_citas, actualizado); luego estampa la conversación
B, reconciliación 4:30 a. m. diario (zona Costa Rica), y endpoint a mano solo gerencia recalcula todo desde cero y corrige derivas
alEscribirCita(antes, despues):
  telA = normalizar(antes.telefono), telD = normalizar(despues.telefono)
  si telA == telD:
     si no hay teléfono o el monto no cambió → salir
     sumar (montoD - montoA) a analitica_clientes[telD], citas +0
  si no:
     si telA: restar montoA y 1 cita
     si telD: sumar montoD y 1 cita

estamparConversacion(tel):
  si no existe whatsapp_conversations[tel] → no crear (no se crean conversaciones vacías)
  esCliente = total_facturado > 0
  boardCol = columnaDelTablero({...conv, esCliente})
  si nada cambió (total, esCliente y boardCol) → no escribir (evita bucles)
  escribir esCliente, totalFacturado, boardCol, clienteStampAt

Lección: la guarda contra bucles también mira boardCol; si solo mirara esCliente, una columna desfasada nunca se arreglaría.

Espejo liviano cartera_telefonos/{tel}

Un doc por teléfono de cliente de cartera (cuentaId, nombre, actualizado_en). Lo escribe el push de cada cuenta y un endpoint de sincronización total (borra los que ya no están). Lo leen agenda y ruta para mostrar un maletín junto al nombre del cliente, WhatsApp de empresa para pintar en verde, y la medición de leads para excluir clientes.

Cartera (cartera.html)

Propósito

Administrar los clientes corporativos o recurrentes ("cartera") con sus sucursales ("sedes"), revisar cómo va el control de plagas de cada local mes a mes, dar acceso al portal del cliente y gestionar tickets y refuerzos. Lo usan los ejecutivos de cuenta (ven sus clientes), gerencia (todos) y una asistente con permiso de ver toda la cartera.

Modelo de datos (fuente de verdad local, espejada al portal)

ACCESO (teléfono con el que se entra)  ──1:N──>  CUENTA (de quién son los reportes)  ──1:N──>  SEDE (local)
Colección Id Campos
portal_accesos teléfono de login activo, nombre, cuentas[], creado_por, creado_en
portal_cuentas teléfono de la cuenta nombre, total_sedes, ultimo_servicio, ejecutivo, datos de contacto y de pago, logo, código interno de cliente (contador en portal_meta/contador_clientes)
portal_cuentas/{c}/sedes sedeId nombre, nombre_manual, coordenadas, provincia/cantón/distrito, inferida, fusionada_en, place_id, machote del servicio
portal_cuentas/{c}/reportes citaId recorte del reporte del técnico con sedeId, plagas con su condición, segmentos
portal_cuentas/{c}/visitas citaId fecha, ventana de hora, tipo de servicio, sedeId
portal_cuentas/{c}/documentos docId certificados y hojas de seguridad
portal_cuentas/{c}/refuerzos id del cliente visita extra sin costo solicitada
portal_tickets auto tickets internos, de gerencia o visibles al cliente
portal_admin_log auto bitácora de acciones de admin (incluida "simulación")

Las citas se atan a la cartera con dos campos de raíz: portal_cuentaId y portal_sedeId.

Reglas: portal_cuentas, portal_accesos y portal_meta cerradas al navegador (if false) y excluidas del comodín de empleados. Solo las tocan funciones con Admin SDK; la página habla por endpoints portalAdmin* con el idToken del empleado y permiso portal_clientes_admin.

Inferencia de sedes (portal-push.js)

El vínculo cita a sucursal se perdió en el modelo viejo, así que las sedes se infieren:

para cada cita de la cuenta en los últimos 24 meses:
  si metadata.cliente_nombre distingue la sucursal (cadenas: "Marca Escazú", "Marca Heredia") → sede por nombre
  si no → agrupar por coordenadas con radio 120 m
     (con 75 m la misma sede se partía en dos: el GPS tomado en la acera o el parqueo separan 80 a 100 m)
guardar lo inferido como documento real (inferida=true) para que gerencia renombre y fusione
  y la corrección sobreviva a la siguiente corrida
geocodificación inversa con un servicio abierto (OSM) para provincia/cantón/distrito

Triggers sobre citas: portalPushAlCerrarReporte (al cerrar el reporte empuja el recorte a la cuenta; 1 GiB y concurrencia 4 porque una cadena grande pasaba de 512 MiB) y portalPushAlAgendar (una cita nueva de un teléfono de cartera crea la visita próxima; ignora citas con portal_excluir). Endpoint portalPushCuenta para empujar una cuenta a mano.

Espejo al proyecto del portal: triggers espejoPortalCuenta/Sede/Reporte/Visita/Acceso replican cada escritura del SoT local al proyecto Firebase del portal con la misma forma (set sin merge, delete si se borró). Puente de una sola vía: el proyecto del portal no tiene credenciales hacia el interno.

Pestañas

Pestaña Contenido
Cartera lista de cuentas por ejecutivo, búsqueda, lápiz para editar, botón de bicho por fila (ticket)
Regular clientes de la agenda que no están en cartera; "Pasar a cartera" o revisar con el lápiz
Ficha cliente cuatro zonas: datos del cliente (editables), dashboard del mes, buscador de sucursales, reportes del mes. Encabezado "Por ejecutivo" solo para gerencia. Columna izquierda "Revisión de cartera": cada ejecutivo marca el cliente que dejó ordenado, gerencia aprueba
Accesos dar acceso a un teléfono (qué cuentas ve), lista de accesos, ojo para ver como ese cliente
Problemas tickets abiertos con contador
Vista cliente (oculta) el portal real dentro de un iframe, entrando como un teléfono sin código

KPIs del mes en la ficha (cada cifra es un botón que filtra la lista de sedes):

  • Servicios: citas cobradas del mes menos las de cortesía (refuerzos).
  • Refuerzos: servicios sin costo (refuerzos, garantías, revisiones) + los pendientes sin ruta.
  • No agendadas: sedes de la cuenta sin cita en el mes.
  • Segmentos de plaga (roedores, insectos, especiales): cuántos locales quedaron en rojo y en amarillo según la peor condición del grupo en sus reportes (PESO_COND = rojo 3, amarillo 2, verde 1). Lo que no calza en un grupo cae en "especiales".
  • Secciones "Hay que atender ya" (locales en rojo), "Para seguirles el paso" (amarillo) y "Cómo ha venido el control, mes a mes".

Tickets de cartera

TIPOS_TICKET: ubicacion_equivocada, nombre_equivocado, sucursal_duplicada, no_es_de_esta_cartera, falta_sucursal, otro. Visibilidad interno | gerencia | cliente. Estados abierto | realizado | descartado | pausa (cerrado es el nombre viejo de realizado y se convierte). El técnico de ruta puede leer los tickets internos y de cliente de la sede que va a visitar sin tener el permiso del portal; para escribir se exige el permiso.

Refuerzos

Visita extra sin costo que se pide cuando un local sigue con plaga entre servicios. El id lo genera quien pide, para subir las fotos a Storage antes de crear el documento y que nunca quede un refuerzo a medias.

Machote del servicio

Plantilla por sede con lo que normalmente lleva la cita (servicios, productos, notas). Se limpia del lado servidor y se prohíben campos de agenda: fecha_dia, fecha_mes, inicio_gmt6, fecha, hora, ruta, turno, turno_emoji, estado. Listas acotadas a 40 elementos.

Otros endpoints de cartera

portalAdminFusionar (dos cuentas), portalAdminFusionarSede (con "ancla" para que la fusión sobreviva al push), portalAdminVincularPlaces (place_id por sede), portalAdminCrearSede, portalAdminBorrarSede, portalAdminBloqueo (bloquear cliente), portalAdminEjecutivos, portalAdminSellarIdentificadores (escribe portal_cuentaId/portal_sedeId en citas), portalAdminArbol (árbol cuenta, sedes, reportes resumidos), portalAdminTranscribir (audio de reporte a texto con un vocabulario del oficio), clienteFicha.

Portal de clientes

Propósito

Que el cliente corporativo vea sus sedes, reportes, próximas visitas, certificados y tickets visibles, y pueda cotizar y pedir fecha, sin ninguna credencial que alcance la base interna.

Arquitectura

Proyecto Firebase separado (Firestore propio, Auth solo con proveedor anónimo y custom tokens). La página de sedes del portal se genera con un script desde la ficha de cartera interna: copia el HTML y solo cambia los "enchufes" (sesión del portal en vez de la del empleado; las llamadas pasan por un endpoint clienteCartera). Si un reemplazo deja de calzar, el script se detiene y dice cuál. Nunca se edita a mano. Un CSS propio apaga lápiz, accesos, machote, pedir refuerzo, tickets internos y acciones de edición (con visibility para no mover la rejilla).

clienteCartera (en el proyecto interno) verifica el token del portal, exige que la cuenta esté en el claim cuentas, y devuelve el paquete sin datos internos: monto como 1/0 (solo para separar refuerzos), sin ejecutivo, sin facturación, sin comentario_interno, solo tickets con visibilidad cliente.

Entrada con código por WhatsApp

Parámetro Valor
Largo del código 6 dígitos
Vigencia 10 minutos
Intentos por código 5
Códigos por teléfono 8 cada 15 minutos
Pedidos por IP 30 por hora
Duración de la sesión 7 días
solicitarCodigo(tel):
  rate limit por tel y por IP (colección portal_limites/{clave}_{hora})
  responder SIEMPRE lo mismo exista o no el teléfono (si no, el portal sería un directorio de clientes)
  si el teléfono tiene acceso activo:
     generar código, guardar SOLO su hash en portal_codigos/{tel} con vencimiento e intentos
     llamar al endpoint del proyecto interno portalEnviarCodigo con una llave compartida
verificarCodigo(tel, codigo):
  comparar hash, contar intentos
  leer portal_accesos[tel].cuentas
  firmar custom token con uid "tel_<tel>" y claim cuentas=[...]
  registrar en portal_bitacora (quién, cuándo, IP)

portalEnviarCodigo (proyecto interno): acepta solo POST con la llave compartida en un header, comparada en tiempo constante; normaliza el teléfono (mínimo 10 dígitos) y exige código de 6 dígitos; manda una única plantilla de tipo AUTHENTICATION con el código en el cuerpo y en el botón de copiar. El nombre de plantilla está fijo en el código, así que la llave comprometida no sirve para mandar mensajes arbitrarios. Registra el envío en portal_codigos_enviados sin el código.

Decisiones de seguridad que no hay que "arreglar":

  1. allow list: if false en portal_cuentas: con list permitido, un cliente autenticado cosecharía los teléfonos de toda la cartera. Se permite get solo si el id está en el claim.
  2. portal_accesos es ilegible incluso para su dueño (filtraría qué otras cuentas existen); la lista llega por el token.
  3. La sesión dura 7 días y no 30, porque quitar un acceso no surte efecto hasta que caduca el claim; hay botón de revocación que invalida los tokens de ese teléfono.

Pruebas de reglas: 28 en emulador (aislamiento entre clientes, enumeración, colecciones internas, que el cliente no escriba nada) que el workflow corre antes de publicar, y 11 contra producción con un token real.

Simular la vista de un cliente

portalAdminSimular {telefono} (admin con permiso de portal) firma un custom token del proyecto del portal con los mismos claims más simulacion: true y simula: <empleado>, registra en portal_admin_log y el iframe canjea el token. El portal muestra una franja roja y no envía nada (perfil, agendar) en simulación. Si el teléfono no tiene acceso entra con cuentas vacías (lo que vería un cliente nuevo).

Autoservicio (cotizar y pedir fecha)

Endpoints del portal: portalOpciones, portalCotizar, portalGuardarPerfil, portalSolicitar. El teléfono sale siempre del token, nunca del cuerpo.

  • Plagas elegibles: cucaracha, hormiga, araña, otros insectos, mosca, pulga, garrapata, roedor, chinche, comején, zompopa. Inmuebles: casa, apartamento, local, oficina o bodega. Tamaños en tramos: hasta 100 m², 100 a 200, más de 200 (se cotiza como 400).
  • La tarifa se elige igual que el bot de ventas: filtrar por plaga, luego tipo de inmueble, luego metros, y tomar la de menor orden. El tarifario se lee del proyecto interno con una cuenta de solo lectura.
  • portalSolicitar: fecha de mañana en adelante y como máximo a 90 días (sin tope, una solicitud a dos años se perdía); franja mañana (8 a 12), tarde (12 a 17) o cualquiera; coordenadas dentro de Costa Rica (lat 8 a 11,3; lng -86 a -82,5), porque la agenda exige coordenadas; requiere perfil con nombre; hasta 6 plagas. Guarda portal_solicitudes/{tel}_{timestamp}.

Bandeja interna (portal-solicitudes.js): portalSolicitudesListar, portalSolicitudConvertir, portalSolicitudDescartar. Convertir no duplica la lógica de agenda: llama a la misma función crearCita que usa el bot de ventas (valida solapamiento, crea el cliente si no existe). El vendedor decide lo que la solicitud no trae porque el cliente no debe verlo: hora exacta, ruta y monto final.

Garantías

Propósito

Atender reclamos de "la plaga sigue" con un caso trazable: el cliente deja evidencia, el equipo decide y se agenda una visita sin costo. Política comercial: garantía de 30 días para fumigación de casa o apartamento si se cumplen las indicaciones del reporte; en infestación grave hace falta reaplicación a los 14 o 21 días y la garantía corre después de la segunda. No se promete erradicación ni "100 %".

Flujo

1. Agenda: botón "Garantía" en la cita → busca caso existente (cita.garantiaId o garantias.citaOrigenId)
   si no hay: crea garantias/{token} estado "solicitud_enviada" y cita.garantiaId
   muestra enlace público garantia.html?t=<token> (copiar, WhatsApp, o plantilla por el CRM)

2. Cliente abre el formulario sin login: ve su local, sube fotos/videos (fotos a JPEG 1600 px),
   escribe el motivo → estado "pendiente_revision"
   (regla: el update público solo puede pasar de solicitud_enviada a pendiente_revision
    y solo tocar estado, motivo_cliente, fotos, enviado_en, user_agent; Storage acepta imágenes < 10 MB)

3. Trigger garantiaAvisoRespuesta: el robot avisa al grupo de garantías con @ al vendedor
4. garantias.html: el vendedor/gerencia revisa reclamo vs reporte de la cita origen
   decide: aprobada | rechazada (justificación obligatoria) | cliente_desiste | cliente_no_contesta

5. Agendar: busca espacios libres cerca y crea cita ₡0 con metadata.es_garantia, garantia_de
   → caso "realizada" con citaAgendadaId

Estados: solicitud_enviada → pendiente_revision → aprobada | rechazada → realizada, más cliente_desiste y cliente_no_contesta (comentario obligatorio). Cerradas: realizada, rechazada, cliente_desiste, cliente_no_contesta.

Pantalla garantias.html (permiso garantias)

  • Vista tabla (por defecto) y vista tarjetas. Columnas: cliente con contador de garantías del mismo teléfono, teléfono, estado, espera en días, generada, generó, fecha del servicio, días del servicio al reclamo (rojo si > 30), ruta, técnico, condición del reporte, monto, evidencia, motivo, acciones, WA (busca el teléfono en todos los WhatsApp y abre el chat), Espacio 1 a 3.
  • Filtros finos: formulario respondido, evidencia, condición, plazo 30 días, espera, espacio cercano, generó, ruta, repetidos.
  • Fila "Para decidir": plazo, evidencia, condición, servicios cobrados en 6 meses, repetidos.
  • Mensajes al cliente (4 a 5 plantillas: formulario, recordatorio si lleva más de 1 día sin responder, recibido, visita con día y hora, no procede). Cada clic suma a garantias.seguimientos[] {tipo, canal crm|wa_personal|copiado, por, en} (regla: solo crecer).
  • Detalle a dos columnas: reclamo del cliente y reporte de la cita origen (condición, plagas, comentario del técnico, fotos, audios, hoja firmada), con visor propio de fotos y videos.

Espacios cercanos para agendar

citas de hoy a +7 días, rutas 1 a 19
para cada hueco entre citas vecinas de la misma ruta:
  libre real ≥ 30 min; nada antes de 7:00; cierre 18:00 diurnas, medianoche nocturnas
  garantía diurna (cita origen en rutas 1 a 10 o antes de 17 h; sin origen = diurna) → solo rutas 1 a 10
ordenar por distancia haversine al punto de la garantía (o de su cita origen)
mostrar top 10 con botón Mapa y "Agendar aquí"

Refuerzo agendado a mano (garantiaRefuerzoOnCita)

Trigger en citas: cruza toda cita escrita con garantías abiertas del mismo teléfono (desde el día del reclamo hasta 60 días, sin Ruta 20 ni canceladas, que no sea la cita origen).

  • Cita en ₡0 → caso realizada con vinculo: 'auto' y la cita recibe metadata.garantia_de. Se cierra aunque no esté aprobado (decisión: agilidad).
  • Cita con monto → se agrega a refuerzos_posibles[] y aparece un chip "¿Ref?" para confirmar (vinculo: 'confirmado') o descartar.
  • Cancelación con motivo "Cliente no aceptó su visita de garantía" o "Cliente no contesta para su visita de garantía" cierra el caso en cliente_desiste o cliente_no_contesta.

Resumen diario

garantiasResumenDiario 8:00 lunes a sábado, con candado garantias_avisos/{día}, al grupo configurado en wa_equipo_config/garantias.chatId (activo:false lo apaga).

Lección

Hubo una etapa en que un proceso creaba garantías automáticamente leyendo los chats (origen: 'chat', detección con un modelo de lenguaje detectarGarantias). Nadie las revisaba y terminaron borradas en bloque (con respaldo). Hoy cada vendedor genera sus casos. Las de chat quedaban ocultas por defecto.

Citas en rojo (tickets de reporte grave)

Propósito

Toda cita cuyo reporte se cierra en condición "rojo" (infestación grave) obliga al vendedor a mandar el reporte al cliente y ofrecerle un repaso a los 14 días a precio de refuerzo. Volumen observado: cerca de 23 % de los reportes salen en rojo, unos 10 tickets por día.

Apertura (tickets-rojo.js, llamado desde el trigger de citas)

si metadata.reporte.condicion == "rojo" y existe metadata.hora_salida   (sin salida puede ser borrador)
   y la cita es desde la fecha de arranque, no Ruta 20, no ficticia, no demo:
  crear tickets_rojo/{citaId}:
     vendedor = primer vendedor de la cita
     precio_repaso = redondear a ₡100 (monto × pct/100), pct de config_tickets_rojo/general (defecto 70)
     token, enlace
  crear reportes_publicos/{token} (lectura pública por get, sin list; no muestra precio)
si deja de ser rojo: cerrar con cierre "ya_no_es_rojo"

La condición general sube sola a la peor plaga marcada; si el técnico marca grave en la app y moderada en papel, el reporte dice grave (error de captura, no del sistema).

Revisión de la nota (revisarReporteRojo)

Trigger sobre reportes_publicos/{token}. Un modelo de lenguaje revisa el comentario del técnico antes de que lo vea el cliente: groserías, juicios sobre el cliente o su casa, discriminación, datos de terceros, promesas prohibidas (cruza con la política de promesas), salud, comentarios internos. Sin problema, el texto queda letra por letra; con problema cambia solo esa frase; si la corrección alarga el texto o sigue con promesa, la nota no se publica (estado error). Campos: comentario_tecnico (original) y comentario (lo que ve el cliente, vacío hasta revisar). Los botones de enviar se apagan hasta que exista revision. comentario_interno nunca sale.

Cierre automático (ticketsRojoVerificar, cada 10 min)

Se cierra (estado: enviado, envio{canal,tipo,por,en}) si encuentra cualquiera de:

  1. la plantilla de reporte grave enviada por la Central (whatsapp_messages);
  2. un mensaje saliente con el token del reporte o el enlace de la encuesta de esa cita, por la Central o por un teléfono de empresa;
  3. un repaso ya agendado: cita posterior con el mismo teléfono y el mismo nombre de cliente o sede, creada después de la visita (cierre: refuerzo_agendado). Solo teléfono no sirve: hay cuentas con miles de citas al mismo número.

Interfaz: burbuja roja flotante en la agenda; el vendedor ve los suyos, gerencia todos y cambia el %. Botones: plantilla por el CRM, abrir su WhatsApp de empresa con el texto prellenado, copiar, ver reporte.

Satisfacción de clientes (satisfaccion-clientes.js + satisfaccion_clientes.html)

Propósito

Medir qué dice cada cliente después de su visita leyendo todos los WhatsApp que monitorea la empresa (Central y teléfonos de empresa, sin los privados), porque las encuestas casi no se responden (4 %).

Método (versión 2)

Parámetro Valor
Unidad la cita (rutas 1 a 19, sin visitas de garantía ni ficticias)
Ventana desde 1 h después del inicio hasta 14 días, o hasta la siguiente cita del mismo teléfono
Madurez la cita no se toca más a los 15 días; la semana madura con su última cita
Corrida diaria 4:30 a. m.; recalcula los últimos 15 días; solo llama al modelo si la conversación cambió (hash)
Audios se transcriben con un servicio de voz a texto y se guardan en satisfaccion_transcripciones
Categorías (MECE) contento, tramite, duda, reclamo_leve, reclamo_fuerte, silencio (hay chat pero no escribió), sin_chat
Causas del reclamo plaga_igual, plaga_bajo, plaga_volvio, ejecucion_incompleto, ejecucion_metodo, ejecucion_danos, ejecucion_trato, agenda_tarde, agenda_cambios, cobro_distinto, cobro_precio, atencion_seguimiento, atencion_info, otro, ninguna
Plagas cucarachas, hormigas, chinches, roedores, comejen, pulgas, mosquitos_moscas, alacranes_aranas, murcielagos, otra, no_indica

El modelo recibe la cita y los mensajes posteriores marcados CLI (cliente), EP (empresa), [AUDIO] y [ENCUESTA], y devuelve JSON con esquema fijo. aspecto se deriva del prefijo de la causa (plaga a resultado, ejecucion a tecnico, agenda a puntualidad, etc.). Si cambia el criterio se sube VERSION_METODO y se reclasifica todo.

Colecciones: satisfaccion_citas/{citaId}, satisfaccion_semanas/{lunes} (con causas, plagas, pct_plaga), satisfaccion_transcripciones. La tendencia se lee solo con semanas definitivas. Concordancia con clasificación manual: 86 %. Hallazgo: cerca de 60 % de los reclamos son porque la plaga sigue.

Página: KPI, línea "por la plaga", tabla "Por qué reclaman", tabla de plagas, filtro por causa; los elogios van en una sección aparte al final.

Recuperador de cartera (recuperador-cartera.js + recuperador_cartera.html)

Propósito

Recuperar clientes que fumigaron y llevan 6 meses o más sin volver, repartiéndolos entre ejecutivos, con visibilidad de qué plantillas se les mandaron y si contestaron.

Reglas

Parámetro Valor
Dormido última fumigación hace 183 días o más, sin cita a futuro
Servicio válido rutas 1 a 19, monto > 0, fecha pasada, no ficticia
Historia 48 meses de citas
Duración de una toma 14 días; después otro ejecutivo puede quitárselo sin pedir a gerencia (tomar y no trabajar no bloquea para siempre)
Espera entre plantillas 7 días (gerencia puede saltarla)
Plantillas guardadas con detalle las últimas 6 por cliente
Historial guardado 40 eventos
Estados disponible, tomado, contactado, respondio, agendado, recuperado, descartado, no_molestar

Datos

  • recuperador_cartera/{tel}: un doc por candidato (los que salen quedan con activo:false para conservar el resultado).
  • recuperador_cartera_meta/resumen + indice_N: filas compactas en JSON con claves cortas, partidas en trozos de hasta 800 KB, para que la pantalla abra con 3 o 4 lecturas en vez de 10.000. La pantalla superpone en vivo los docs con actualizadoAt > generadoAt.

Funciones

Función Disparo Qué hace
recuperadorCarteraNocturno 4:20 a. m. cruza citas, conversaciones y mensajes de plantilla; solo reescribe docs cuyo hash de campos calculados cambió
recuperadorCarteraRecalcular endpoint, gerencia lo mismo a mano
recuperadorCarteraOnMensaje creación en whatsapp_messages plantilla saliente → contactado y contador; entrante → respondió (venga de donde venga el envío)
recuperadorCarteraOnCita escritura en citas cita futura → agendado; cita nueva más reciente → sale; si estaba tomado → recuperado con monto
recuperadorCarteraTomar endpoint reasigna el lead de WhatsApp al ejecutivo aunque lo tuviera otro vendedor
recuperadorCarteraSoltar endpoint libera
tomar(cliente, ejecutivo):
  whatsapp_conversations[tel] ← assignedTo=ejecutivo, fueraDeClientes=true, embudoVendedor="", boardCol=ejecutivo
  si la conversación no existe: crearla con lastMessageAt=ahora (si no, el embudo no la lista)
  registrar en whatsapp_activity
  NO tocar el libro mayor del reparto (a propósito)

Se envía por la misma función whatsappSend que el embudo y se marca raspadoAt. Plantillas disponibles: las del grupo recuperador, reactivación y fidelización, filtradas por estado APPROVED en plantillas_stats/resumen. Desde el navegador solo se escriben estado, nota, seguimientoAt, descartadoMotivo, historial y actualizadoAt del doc propio; recuperado solo lo pone el servidor. Lección: las dos colecciones tuvieron que excluirse del comodín de reglas de empleados; sin eso el emulador mostró todo abierto.

Lealtad (lealtad.html + lealtadDatos + perfilClienteIA)

Datos

lealtadDatos (permiso lealtad) arma un paquete con tres años de citas (teléfono, fecha, monto, ruta, cliente, vendedores, servicios, dirección, observaciones, canal, es_ficticio), lo comprime con gzip a base64 y lo guarda en cache_lealtad partido en trozos de 900 KB con _meta.generadoEn. Vida del caché: 6 horas (o forzar). Así la pantalla hace una llamada en vez de leer miles de citas.

Clasificación por cliente (en el navegador)

mesesActivos12 = meses con servicio en los últimos 12
si mesesActivos12 ≥ 3 y no hubo servicio ni este mes ni el anterior → "riesgo"
si hubo servicio este mes o el anterior → "activo"
si solo tuvo un mes con servicio en toda la historia → "nuevo"
si no → "perdido"
días desde la última visita: > 30 precaución, > 60 alerta

Vistas: facturación mensual por cliente (6 meses previos, el mes elegido y 2 posteriores; solo clientes activos en 2 o más de los 6 previos), patrón visual de 12 o 24 meses, "Recuperador 3000" (inactivos 6+ meses, solo contactables con autorización de gerencia y por la línea oficial), ventas atribuibles (venta dentro de los 30 días posteriores a un contacto) y auditoría de canal (verificar que esas ventas tengan el canal Recuperador).

perfilClienteIA: dado el historial de un cliente inactivo, un modelo de lenguaje devuelve NOMBRE, RESUMEN, DATO PARA ROMPER EL HIELO, ESTRATEGIA DE CONTACTO, PRECIO ANTERIOR y un MENSAJE de WhatsApp de 4 a 5 líneas con voseo costarricense, firmado como el vendedor que más lo atendió, mencionando que se le mantiene el precio anterior, con 1 o 2 emojis como máximo y sin inventar datos. Los datos se sanean antes de entrar al prompt.

Reporte del cliente con clave (reporteCliente)

Reemplazó una lectura pública de Firestore: el cliente sin login llama con (teléfono, clave) y solo recibe datos si la clave coincide; la clave nunca vuelve al navegador. La colección reportes_cliente pasó a lectura solo de empleados.

Ficticias: la plata cuenta, la visita no

Una cita ficticia (metadata.es_ficticio) es una venta que el técnico cierra en ruta durante otra visita. Copia teléfono y ubicación de la cita origen, sin ingreso, salida ni reporte. Regla para toda métrica nueva: si cuenta visitas (reportes, encuestas, satisfacción, garantías, lealtad en visitas, recuperador, portal) excluye es_ficticio; si cuenta dinero (ventas, comisiones, táctico, canales, atribución a pauta) la incluye.

04

Inventario, bodega y personal

Este capítulo describe cómo el sistema lleva el inventario físico de una empresa de control de plagas (insumos químicos, cebos, materiales, equipo y dotación del personal) y cómo administra al personal (ficha, permisos, horas, planilla, liquidación, vacaciones, tickets y actividades internas). Todo corre sobre el mismo stack del resto del sistema: páginas HTML con JavaScript sin compilar, Firestore como base de datos, Firebase Auth para la sesión y Cloud Functions en Node para lo que no puede quedar en manos del navegador.

Convenciones que aplican a todo el capítulo:

Convención Regla
Moneda Colones enteros, sin céntimos. Los precios se redondean al guardar porque un precio con céntimos terminó multiplicando montos por 100 al releerlos de un formulario.
Unidades de inventario Enteras. Solo los ajustes de auditoría admiten fracciones (existen para sacar las fracciones viejas pegadas al saldo).
Día operacional De 5:00 a 4:59 del día siguiente, hora de Costa Rica (UTC-6). Lo usan citas (fecha_dia), ruteador (fecha), arqueo, despacho y la liquidación de bodegas.
Día contable de una liquidación Día calendario GMT-6. Las liquidaciones de un día se estampan a las 23:00 de ese día; los alistos a las 12:00 del día de ruta.
Identidad de un empleado El username (minúsculas, derivado del correo de la cuenta). Es inmutable y es la llave con la que bodegas, horas, citas y movimientos referencian a la persona.
Permisos empleados/{id}.permisos es un mapa de claves booleanas. Cada página declara su clave en check-permissions.js.

Modelo de datos del inventario

Panorama

Hay una sola tabla de saldos (stock_bodegas) y un solo historial contable (movimientos_inventario). Todo lo demás son catálogos o vistas.

Colección Rol Llave
productos Catálogo de insumos y de dotación (EPP, techo, stock de herramientas). Único catálogo con código de barras y foto. id aleatorio (productoId)
herramientas Herramientas con saldo por bodega (se mueven igual que un producto, no se gastan). id aleatorio (herramientaId)
herramientas_alisto Catálogo de equipos que se asignan a citas, con un saldo global fijo por empresa (saldoTotal). id; las citas lo referencian por NOMBRE
herramientas_catalogo + herramientas_asignaciones + herramientas_reportes Módulo aparte de asignación de herramientas a personas y vehículos con verificación por foto. No toca stock_bodegas. id
bodegas Lugares que pueden tener saldo. id (los de empleados son deterministas)
stock_bodegas Saldo de un ítem en una bodega. {bodegaId}__{itemId}
movimientos_inventario Historial de cada entrada, salida, traslado y ajuste. Nunca se borra (se anula). id aleatorio
cuentas_gasto Destinos contables de los consumos ("Gasto Natural", pérdida, etc.). No son bodegas. id
proveedores Catálogo de proveedores. id
saldos_minimos Saldo meta por grupo de bodegas y producto. {grupo}__{productoId}
servicios Catálogo de servicios vendibles con su receta de productos y herramientas. id
gastos Espejo del gasto declarado por un técnico desde su bodega personal, y reportes de pérdida de herramienta pendientes de aprobación. id
ordenes_reposicion Respaldo de cada reposición masiva hecha desde Arqueo. id
ordenes_compra + contadores Órdenes de compra al proveedor con número consecutivo. número AAAAMMDD-letra
auditorias_bodega, auditorias_bodega_estado, auditorias_sesiones Conteos físicos de Guachimán (evento por ítem, estado vigente por bodega, sesión completa). id / bodegaId
solicitudes_dotacion, verificaciones_dotacion Pedidos de EPP y herramientas personales, y la revisión mensual "Sí tengo". id / bodegaId
catalogo_config/categorias Categorías de producto agregadas por usuarios. doc fijo

No existe una dimensión de producto para analítica: los reportes de consumo leen directo de movimientos_inventario. Los movimientos copian nombre y precio del producto al momento de registrarse (productoNombre, precioUnitario), lo que congela el valor histórico; renombrar un producto no cambia lo ya registrado.

productos

Campo Tipo Significado
nombre texto Nombre visible. No puede quedar vacío.
codigo texto SKU interno corto, solo para mostrar y buscar. Ninguna relación depende de él.
codigoBarras texto EAN de mercado escaneado con pistola o cámara, o código interno generado.
fotoUrl texto Foto cuadrada en Storage (productos_fotos/).
categoria texto "Insumos Gastables" es la principal. Otras: "Stock herramientas", "EPP", "Techo", "Equipo", "Mantenimiento" y las que se agreguen.
unidad texto Se normaliza: "Unidades", "unidad", "1" y vacío son lo mismo. Litros o kilos se respetan.
precioUnitario entero Precio CON IVA, en colones enteros. Valora todo el inventario y cada movimiento.
stockMaximo entero "Unidades máximas" o "mínimo", según tipoLimite.
tipoLimite 'max' o 'min' Cómo se lee stockMaximo. Sin campo es máximo.
unidadesCompra entero De a cuántas unidades se compra (lote mínimo de pedido).
activo booleano Sin el campo, el producto es activo. Inactivo significa "ya no se compra", no "ya no existe".
fechaCreacion, usuarioCreacion, fechaActualizacion, usuarioActualizacion auditoría Quién y cuándo.
precioActualizadoEn, precioActualizadoPor auditoría Última vez que una factura cambió el precio.

Código de barras interno: secuencial de 11 dígitos que empieza en 10000000001. Se eligió un largo no estándar (EAN-13 y UPC tienen 12 o 13) para no chocar con códigos reales, y se valida único contra los ya cargados en pantalla. El documento de arquitectura interno reconoce que la unicidad no se valida contra la colección completa.

Tres estados de un producto, que la interfaz pinta distinto:

Estado Condición Tratamiento
activo activo !== false Se compra y se usa.
inactivo con unidades activo === false y saldo > 0 Amarillo, penúltimo en la lista: hay que gastarlo. Se puede mover, transferir y tirar a gasto.
inactivo en cero activo === false y saldo 0 Gris, último. Solo aparece buscándolo.

Apagar un producto no mueve inventario. Para comprarlo de nuevo la pantalla de compra pregunta si se reactiva en el momento de registrar la línea (no al elegirlo del select, porque "elegir es mirar").

Borrado de un producto o herramienta:

si existencia_total > 0:
    no se ofrece ni borrar ni desactivar (hay que liquidar o ajustar primero)
si no, si tiene movimientos históricos:
    activo = false (los reportes siguen resolviendo el nombre)
si no:
    borrar el documento y sus filas de stock en cero
la existencia se relee de Firestore antes de decidir (la caché de la página puede estar vieja)
el borrado exige resolver un reto matemático

servicios (la receta)

Cada servicio vendible lleva:

Campo Contenido
nombre, precio Datos comerciales.
productosAsociados[] { productoId, nombre, cantidadPorUnidad }: cuánto consume UNA unidad del servicio.
herramientasAsociadas[] { nombre }: equipo que el servicio siempre necesita (por ejemplo una vaporizadora).

La cita guarda metadata.servicios[] = { id, cantidad, precio, comentarios }, opcionalmente metadata.productos[] = { id, cantidad } (productos sueltos, flujo antiguo pero vigente para cargas puntuales), metadata.equipo[] (nombres de herramientas) y metadata.excluidos = { productos[], herramientas[] } (lo que ESTA cita no lleva aunque el servicio lo incluya; se quita con el basurero del detalle sin tocar el catálogo).

requerido_de_cita(cita):
    excluidos = cita.metadata.excluidos.productos
    para cada servicio s en cita.metadata.servicios:
        def = catálogo de servicios por id (o por nombre si no hay id)
        para cada pa en def.productosAsociados:
            si pa.productoId en excluidos: saltar
            acumulado[pa.productoId] += (s.cantidad o 1) * (pa.cantidadPorUnidad o 1)
    para cada mp en cita.metadata.productos:
        si mp.id en excluidos: saltar
        acumulado[mp.id] += (mp.cantidad o 1)

bodegas y sus tipos

Campo Significado
nombre Convención "Tipo - Nombre Apellido" para las de empleados ("Personal - ...", "Especial - ...", "Herramientas - ...").
tipo Ver tabla de tipos.
empleadoId username del dueño (algunas viejas guardan el id del documento; todo el código resuelve ambos).
vehiculoId Para las de vehículo.
activa false = dada de baja. Solo se puede dar de baja en cero.
inactivadaPor 'empleado-inactivo' o 'empleado-departamento' cuando la apagó la sincronización automática.
esCentral Marca las bodegas que son de todos (principal, central de herramientas).
autoGenerada, autoKey autoKey = "{username}::{tipo}" para las derivadas del empleado.
clasificacionRutero Solo tipo rutero: diurno, nocturno, termitero, gerencia, rural. Define el grupo de saldo mínimo.
ajustesSaldoMinimo { productoId: delta } que afina el mínimo del grupo para esa bodega.
Tipo Qué es Vinculada a Puede recibir
principal Bodega central: recibe las compras y de ahí sale todo. nadie Cualquier categoría
rutero Bodega personal del técnico: lo que carga siempre (su "kit"). empleado Solo Insumos Gastables
especial Insumos del día para las citas de su ruta. Se liquida por día. empleado Solo Insumos Gastables
herramientas Dotación personal (EPP, Techo, Stock herramientas) o la central de herramientas. empleado o central Stock herramientas, EPP, Techo
apv Bodega de un vehículo. Fuera de uso desde el 21-oct-2026: no se lista, no se liquida, sus mínimos cuentan cero, los documentos siguen en la base. vehículo (fuera de uso)
transitoria Sala de espera del alisto de una ruta que todavía no tiene técnico. Se crea sola la primera vez. nadie Insumos
otros Cualquier tipo no reconocido se agrupa como "Otros".

Regla de categorías por bodega (servidor, desde 21-oct-2026; EPP y Techo agregados el 22-oct-2026):

clase(categoria):
    normalizada = sin tildes, minúsculas
    si vacía, "sin categoria" o empieza con "insumo": "insumos"
    si "stock herramientas", "epp" o "techo": "stock_herramientas"
    si no: "otra"

al registrar un movimiento de producto (excepto reversion y ajustes):
    para cada pata que ENTRA a una bodega:
        si clase == "insumos": permitido
        si bodega.tipo == "principal": permitido
        si clase == "stock_herramientas" y bodega.tipo == "herramientas": permitido
        si no: rechazar con mensaje
    lo que SALE no se cuida, para poder vaciar lo que había antes de la regla

Bodegas derivadas del empleado (bodegas-empleado.js)

La bodega de un técnico no la crea nadie a mano: es consecuencia de que el empleado exista. Todo empleado del departamento de Fumigación tiene tres bodegas: rutero, especial y herramientas.

Regla Detalle
Id determinista bod-{slug}-{rut|esp|her}, donde slug es el username con _ y espacios cambiados por -. Sin guion bajo a propósito: el id de stock_bodegas parte cadenas por __.
Idempotente Solo setDoc con id determinista, nunca addDoc. Dos clics no crean dos bodegas.
Adoptar, no recrear Si ya existe una bodega del tipo con otro id, se adopta (se normaliza empleadoId al username, se marca autoGenerada, se renombra a la convención). Nunca se cambia el id porque stock y movimientos apuntan a él.
Duplicados Si un empleado tiene dos bodegas del mismo tipo, no se toca ninguna y se reporta el problema.
Elección de "la" bodega Prioridad rutero 0, especial 1, apv 2, herramientas 3, principal 4, desempate por nombre. Antes se tomaba la primera que devolvía Firestore, "una lotería que decide de dónde salen sus gastos".

Sincronización de estado al guardar la ficha:

sincronizarEstadoBodegas(empleado, estadoNuevo, saleDeFumigacion):
    inactivar = saleDeFumigacion o estadoNuevo == "inactivo"
    reactivar = no saleDeFumigacion y estadoNuevo == "activo"
    (Vacaciones y Suspendido no tocan nada)
    para cada bodega propia de tipo rutero/especial/herramientas:
        si inactivar y bodega activa:
            si unidades > 0: devolver en "conSaldo" (hay que liquidarla primero)
            si no: activa=false, inactivadaPor=motivo
        si reactivar y bodega.activa == false y bodega.inactivadaPor existe:
            activa=true, inactivadaPor=null
            (una apagada a mano por un administrador no resucita sola)

stock_bodegas

Campo Significado
bodegaId Bodega.
productoId o herramientaId Ítem (uno de los dos).
tipo 'producto' o 'herramienta'.
stockActual Saldo.
stockMinimo Siempre 0 al crear (informativo).
createdAt, ultimaActualizacion Marcas de tiempo.
ajustadoManualmenteEn, ajustadoManualmentePor, historialAjustes[] Solo si Auditoría de Stock lo ajustó a mano.

El id es {bodegaId}__{itemId}. Existen "duplicados heredados" (dos documentos para el mismo par, de escritores viejos sin id determinista). Toda lectura SUMA todos los registros del par y toda escritura deja el total en el primero (orden por id) y pone en 0 los demás. Leer uno solo daba "stock insuficiente" falso o mostraba menos de lo que había.

movimientos_inventario

Campos comunes:

Campo Significado
tipo Ver tabla de tipos.
tipoItem 'producto' o 'herramienta'.
productoId / herramientaId, productoNombre / herramientaNombre Ítem y copia de su nombre.
origen, origenNombre Bodega de salida (o el texto "Compra" en compras).
destino, destinoNombre Bodega de entrada, o id de cuenta de gasto en un gasto.
cantidad Número positivo, entero salvo ajustes.
precioUnitario, total Valoración con IVA al momento.
fecha Fecha CONTABLE (la que decide a qué día pertenece).
fechaRegistro, createdAt Cuándo se digitó de verdad.
motivo, observaciones Texto libre y motivos estándar ("Pedido Especial", "Reposición de saldo meta", "Faltante en auditoría (Guachimán)", etc.).
empleado, usuarioId A quién se le atribuye (despacho lo usa para "a quién se le despachó").
firmadoPor, firmadoPorUid, firmadoPorEmpleadoId, firmadoEn, origenEscritura Firma puesta por el servidor con el usuario del token.
esPedidoEspecial Transferencia de alisto a una bodega especial.
origenPantalla, rutaOperativa Pantalla que lo creó y ruta del día.
cuentaGasto, cuentaGastoId, recibo En gastos.
numeroFactura, precioNeto, iva, totalNeto, totalIva, montoFacturaDigitado En compras.
movimientoOriginalId, tipoOriginal En reversiones.
Estado anulado, anuladoEn, anuladoPor, anuladoPorUid, anuladoMotivo, revertido, revertidoEn, revertidoPor, corregidoEn, corregidoPor, historialCorrecciones
Revisión revisado, fechaRevision, usuarioRevision, auditoria, auditado, auditoriaCorrecta, detalleAuditoria, auditoriaEstado, auditoriaNota
Marcas de despacho cargaManual, quitadoDesdeDespacho, noRecibidoEnRuta, liquidacionBulk

Tipos de movimiento y sus "patas" (qué bodegas tocan y con qué signo). Es la única definición; se usa para aplicar y, con el signo invertido, para revertir:

Tipo Necesita Efecto
compra destino +cantidad en destino
reversion destino +cantidad en destino (devolver un gasto a una bodega)
ajuste_entrada destino +cantidad en destino (cuadre contra físico)
devolucion_compra origen -cantidad en origen (deshacer una compra; antes se usaba reversion y "se inventaba inventario")
transferencia origen y destino distintos -origen, +destino
devolucion origen y destino distintos -origen, +destino
gasto origen -cantidad en origen; el destino es una cuenta de gasto y no genera pata
ajuste_salida origen -cantidad en origen

Cualquier otro tipo se rechaza. Antes solo se avisaba en consola y el movimiento se guardaba sin tocar ninguna bodega, pero la auditoría lo contaba: "ese fantasma es exactamente lo que descuadra los saldos". También se rechazan los campos viejos bodegaOrigen y bodegaDestino, que dejaban el movimiento invisible para la auditoría.


Motor de movimientos: procesarMovimientoInventario y la función movimientoInventario

Propósito

Una sola puerta para mover inventario. Garantiza que el movimiento y los saldos se escriben juntos (o no se escribe nada), que no se saca lo que no hay, que no entran fracciones y que solo quien puede mueve cada bodega.

Historia y arquitectura

Hasta octubre de 2026 el navegador escribía stock_bodegas y movimientos_inventario directamente con una transacción del SDK de cliente, bajo una regla comodín que dejaba a cualquier empleado tocar cualquier saldo. Se movió a una Cloud Function HTTP (movimientoInventario, archivo functions/inventario.js) que repite exactamente la misma lógica con el Admin SDK, firma el movimiento con el usuario del token y revisa permisos. El helper del navegador (inventario-movimientos-utils.js) conserva la misma firma y valor de retorno, así que ninguna pantalla cambió.

Transición: si la función responde 404 (no desplegada) o la red falla antes de recibir respuesta, el helper cae a la transacción directa vieja. El servidor nunca responde 404 por un error de negocio, justamente para no confundir ese caso. Hoy las reglas ya cierran la escritura directa (ver más abajo), así que el camino de respaldo solo funciona para quien tiene permiso de administración de bodega.

Endpoint

POST movimientoInventario con Authorization: Bearer <idToken>.

Cuerpo Respuesta
{ accion: 'procesar', tipoItem, movimiento } { ok: true, id }
{ accion: 'revertir', movimientoId, opciones: { soloBorrarRegistro, motivo } } { ok: true, movimiento }

Códigos: 401 sin token o sin ficha de empleado activa; 403 sin permiso; 409 con codigo: 'SALDO_YA_AJUSTADO' y marcas[]; 400 errores de negocio; 500 internos. Configuración: región única, máximo 10 instancias, 60 s, 256 MiB, CORS restringido a los dominios propios.

Los Timestamp y serverTimestamp() no viajan en JSON. El navegador los codifica como { __tipo: 'timestamp', seconds, nanoseconds } o { __tipo: 'serverTimestamp' } y el servidor los reconstruye. Cualquier otro FieldValue (increment, arrayUnion) o una referencia de documento se rechaza en el navegador.

Algoritmo de procesar

procesar(usuario, cuerpo):
    mov = decodificar(cuerpo.movimiento)
    borrar de mov los campos de firma (firmadoPor, firmadoPorUid, ...) : la firma la pone el servidor
    borrar de mov los campos de estado (anulado, revertido, corregido...) : un movimiento nace vivo
    validarMovimiento(mov):
        rechazar bodegaOrigen/bodegaDestino
        exigir productoId o herramientaId, y tipo
        exigir origen si tipo en {transferencia, devolucion, gasto, ajuste_salida, devolucion_compra}
        exigir destino si tipo en {transferencia, devolucion, compra, reversion, ajuste_entrada}
    cantidad = número(mov.cantidad); rechazar si no es finito o <= 0
    si cantidad no es entera y tipo no es ajuste_entrada/ajuste_salida: rechazar
        ("se rechaza en vez de redondear: quien tecleó 2,5 se iría creyendo que movió 2,5")
    patas = patasDeMovimiento(mov, cantidad)
    exigirPermisoMovimiento(usuario, mov.tipo, bodegas de las patas)
    exigirCategoriaPermitida(mov, patas)
    para cada pata: refs = consultar stock_bodegas donde bodegaId==pata y productoId==item, ordenar por id
        (el SDK no permite consultas dentro de una transacción; se resuelven antes)
    si mov.empleado vacío: mov.empleado = usuario
    transacción:
        para cada pata (todas las lecturas primero):
            snaps = leer cada ref (o la ref determinista bodega__item si no había)
            total = suma de stockActual de los existentes
            nuevo = limpiarSaldo(total + delta)
            si delta < 0 y no hay registro: error "No se encontró stock"
            si delta < 0 y nuevo < 0: error "Stock insuficiente. Disponible X, solicitado Y"
        para cada pata (escrituras):
            si no había registro: crear {bodegaId, item, tipo, stockActual: nuevo, stockMinimo: 0}
            si no: principal.stockActual = nuevo; duplicados.stockActual = 0
        crear movimiento con { ...mov, cantidad, firma }
    devolver id

limpiarSaldo(n):
    si |n - round(n)| < 0,000001: devolver round(n)
    si no: devolver n con 6 decimales
    (39,01 + 0,99 da 40,000000000000004 y la cola de bits viajaría para siempre)

Si otro usuario modifica una referencia entre la lectura y la escritura, Firestore reintenta la transacción completa.

Permisos por tipo de movimiento

PERMISOS_ADMIN = [administrador_bodega, guachiman, arqueo_inventario, arqueo]
PERMISOS_MUEVEN_CUALQUIER_BODEGA = [traslado_expres, despacho, calendario, logistica, ruteador]
TIPOS_SOLO_ADMIN = [compra, ajuste_entrada, ajuste_salida, devolucion_compra, reversion]
TIPOS_OPERATIVOS = [gasto, transferencia, devolucion]

exigirPermisoMovimiento(usuario, tipo, bodegas):
    si gerencia o tiene algún PERMISOS_ADMIN: permitir
    si tipo en TIPOS_SOLO_ADMIN: 403
    si tipo no en TIPOS_OPERATIVOS: 400
    si tiene algún PERMISOS_MUEVEN_CUALQUIER_BODEGA: permitir
    si alguna bodega tiene empleadoId == usuario: permitir
    si esEspecialDeCompanero(usuario, tipo, bodegas): permitir
    403 "Solo podés mover inventario de tus propias bodegas"

Regla "los especiales son de la ruta, no de una persona" (20-oct-2026): si en una ruta andan varios técnicos, cualquiera puede liquidar el especial de un compañero, a gasto o de vuelta a la principal.

esEspecialDeCompanero(usuario, tipo, bodegas):
    tipo debe ser gasto, devolucion o transferencia
    duenos = dueños de las bodegas especiales activas involucradas que no son el usuario
    si no hay duenos: no
    si tipo != gasto: debe haber un solo dueño y la otra bodega debe ser la principal
    dias = [día operacional de hoy, día operacional de ayer]   (liquidación de la mañana siguiente)
    rutas = documentos de ruteador con fecha en dias
    sí, si alguna ruta tiene en tecnicos[] al usuario Y a todos los dueños

Revertir

Deshacer un movimiento devuelve el stock y ANULA el registro (no lo borra). Antes se borraba, y con eso "se iba la única prueba de que algo había pasado".

revertir(usuario, movimientoId, opciones):
    mov = leer movimiento; si no existe: error
    si opciones.soloBorrarRegistro:
        exige PERMISOS_ADMIN o gerencia
        transacción: si sigue existiendo, borrarlo (los saldos quedan como están)
        terminar
    marcas = []
    si mov.anulado: "anulado en Auditoría de Stock"
    si mov.corregidoEn o corregidoPor: "corregido a mano"
    si mov.revertido: "ya revertido antes"
    si hay marcas: 409 SALDO_YA_AJUSTADO (devolverlo sumaría el stock dos veces)
    patas = patasDeMovimiento(mov) con el signo invertido
    exigirPermisoMovimiento(usuario, mov.tipo, bodegas)
    transacción:
        releer mov; si desapareció: error
        volver a calcular marcas con lo leído DENTRO de la transacción
            (dos personas revirtiendo a la vez pasaban el chequeo de afuera)
        leer y validar patas (si el stock ya se consumió, falla y no se anula nada)
        escribir saldos
        mov.anulado = true, anuladoEn, anuladoPor (del token), anuladoPorUid, anuladoMotivo

Todo lo que suma inventario (reportes, arqueo, auditoría, despacho) descarta los movimientos con anulado: true.

Reglas de Firestore

Colección Lectura Escritura
stock_bodegas cualquier empleado crear, actualizar y borrar solo administrador_bodega o gerencia (lo usan el retiro de productos o bodegas y el ajuste manual de Auditoría de Stock)
movimientos_inventario cualquier empleado crear y borrar: nadie desde el navegador. Actualizar: administrador_bodega o gerencia, solo sobre una lista cerrada de campos de anotación (revertido, corrección, auditoría, revisión, edición de precio y motivo); arqueo_inventario solo sobre las marcas de revisión

Campos anotables: revertido, revertidoEn, revertidoPor, tipo, cantidad, origen, destino, anulado, fecha, corregidoEn, corregidoPor, historialCorrecciones, auditoria, revisado, fechaRevision, usuarioRevision, auditado, auditoriaCorrecta, detalleAuditoria, auditadoPor, fechaAuditoria, precioUnitario, total, motivo, observaciones, lastModified, editadoSinAjustarSaldo, auditoriaEstado, auditoriaNota.

Lecciones que el código deja anotadas

  • Movimiento y saldo en una transacción: antes el movimiento se guardaba primero y una validación fallida dejaba un movimiento fantasma; si fallaba la segunda pata de una transferencia, la cantidad desaparecía del sistema.
  • La cantidad se guarda como número: si llegaba como texto "3" se escribía tal cual y las sumas la concatenaban.
  • Un decimal no se corrige solo: entra una vez y todo lo que pasa después suma enteros encima de esa fracción. Por eso la puerta se cerró en el motor y no en cada pantalla.
  • empleado no se pisa con el token porque despacho y bodeguita lo usan para atribuir; quién lo hizo de verdad queda en firmadoPor.
  • Las funciones de recálculo masivo de saldos desde el historial se eliminaron: ignoraban anulados y devoluciones, contaban doble los duplicados y pisaban los ajustes firmados. Los saldos ahora solo se corrigen en Auditoría de Stock, con motivo y valor anterior.

Ciclo de vida de un producto

Paso Quién Pantalla Documento que se crea Efecto en stock_bodegas
1. Alta en catálogo Bodega Catálogo de inventario o Guachimán productos (nombre, categoría, unidad, precio con IVA, máximo o mínimo, lote de compra, código de barras, foto) Ninguno
2. Orden de compra Bodega Guachimán, "Pedido" ordenes_compra (número AAAAMMDD-A, líneas sin precio, proveedor en texto) Ninguno; alimenta el "en camino"
3. Compra (ingreso a principal) Bodega Modal "Registrar compra" (Transferencias o Guachimán) Un movimientos_inventario por línea: tipo: compra, origen: 'Compra', destino: principal, precioUnitario y total con IVA, precioNeto, iva, numeroFactura, montoFacturaDigitado. Si el precio cambió, actualiza productos.precioUnitario +cantidad en principal
4a. Alisto del día (especiales) Despacho Despacho, "Alistar" transferencia principal a bodega especial del técnico (o transitoria si la ruta no tiene técnico), motivo: 'Pedido Especial', esPedidoEspecial: true, rutaOperativa, fecha = mediodía del día de ruta -principal, +especial
4b. Reposición del kit personal Bodega o arqueo Saldos Mínimos u hoja de reposición de Arqueo transferencia principal a rutero, motivo: 'Reposición de saldo mínimo' o 'Reposición de saldo meta'; en Arqueo además ordenes_reposicion -principal, +rutero
4c. Envío puntual Bodega Arqueo, "Envío puntual", o Transferencias transferencia principal a cualquier bodega -principal, +destino
5a. Gasto en cita (especial) Técnico o compañero de ruta Bodeguita, Ruta (liquidación) o Arqueo gasto desde especial contra la cuenta "Gasto Natural", recibo obligatorio, fecha = 23:00 del día contable -especial
5b. Gasto del kit personal Técnico Bodeguita, "Gasto" gasto desde rutero, motivo: 'Gasto de rutero', revisado: false, y un espejo en gastos con estado: 'pendiente_auditoria' -rutero
6. Devolución de sobrante Técnico o compañero Bodeguita, Ruta, Arqueo o Despacho transferencia de especial a principal ("Devolución desde operario", "Devolución por eliminación en cita", "Devolución de especial no recibido") -especial, +principal
7. Revisión del gasto Bodega o arqueo Revisar gastos, Auditoría de liquidaciones Anota revisado, fechaRevision, usuarioRevision o auditoriaEstado, auditoriaNota en el movimiento Ninguno
8. Conteo físico Bodega (Guachimán) Guachimán auditorias_bodega (evento por ítem), auditorias_bodega_estado/{bodega}, auditorias_sesiones Ninguno si cuadra
9a. Faltante Bodega Guachimán gasto contra la cuenta elegida, motivo: 'Faltante en auditoría (Guachimán)', observaciones: 'Sistema X, físico Y' -bodega auditada
9b. Sobrante Bodega Guachimán compra sin origen hacia la principal, motivo: 'Excedente de auditoría (Guachimán)' (lo que sobra se recoge y se deposita en la principal) +principal; la bodega auditada no cambia
9c. Fracción residual Bodega Guachimán ajuste_salida o ajuste_entrada, motivo: 'Ajuste por auditoría (Guachimán)' Cuadra la bodega auditada
10. Corrección de saldo guardado Bodega Auditoría de Stock Escribe stock_bodegas directo, con historialAjustes[] firmado y motivo. No crea movimiento Saldo nuevo
11. Deshacer un error Bodega Transacciones, Arqueo, Bodeguita (30 min) El original queda con anulado: true; una reversión de gasto o compra crea reversion o devolucion_compra con movimientoOriginalId y marca el original revertido: true Inverso del original
12. Retiro Bodega Catálogo productos.activo = false (o borrado si nunca se movió) Ninguno; con saldo no se permite

Mapa de pantallas de inventario y permisos

Página Clave de permiso Para qué
administrador_bodega.html administrador_bodega Panel con tarjetas hacia todas las pantallas de bodega, más la auditoría de transacciones por día
admin_inventario_*.html (todas) administrador_bodega (por prefijo) Catálogo, bodegas, transferencias, transacciones, saldos, reportes, anomalías, gastos, proveedores, cuentas, herramientas, pedidos especiales
catalogo_productos.html administrador_bodega Catálogo imprimible tamaño carta con código de barras
auditoria_stock.html, auditoria_transacciones.html, administrador_bodega_recuperar.html administrador_bodega Auditoría de saldos, auditoría por día, recuperación de bodegas
arqueo_inventario.html, saldos_minimos.html, etiquetas_bodegas.html arqueo_inventario Liquidación de especiales, matriz de saldos, reposición, mínimos, etiquetas
guachiman.html guachiman Conteo físico de una bodega, compra, transferencia, orden de compra, rótulos
despacho.html despacho Tablero del día por ruta, alisto, liquidación, solicitudes de dotación, catálogos de servicios y herramientas
bodeguita.html (y operario_bodega.html, que redirige) operario_bodega Bodega personal del técnico
reposicion.html reposicion Qué comprar según consumo
herramientas.html herramientas Verificación por foto de las herramientas asignadas
herramientas_admin.html, alisto-herramientas-audit.html herramientas_admin Asignación de herramientas y limpieza del catálogo de alisto
reporte_gasto_inventario.html reportes_gerenciales o administrador_bodega Gasto del inventario personal por semana, mes y técnico
propuesta_reposicion_bodega.html, arquitectura_inventario.html reportes_gerenciales Documentos internos estáticos
admin_inventario_liquidar_especiales.html redirige a Arqueo Nombre viejo
admin_inventario_transferencias_v2.html retirada Su reversión de compras sumaba el stock dos veces
alisto.html redirige a Despacho Retirado el 26-ago-2026

Herencia: mientras una persona no tenga la clave nueva escrita en su ficha, arqueo_inventario, traslado_expres y guachiman se heredan de administrador_bodega, y reposicion de reportes_gerenciales.


Catálogo de inventario (admin_inventario_productos)

Propósito

Administrar productos y herramientas: precio, categoría, unidad, límites, lote de compra, código de barras, foto y estado. También es la vista de "cuánto hay y cuánto vale" por producto.

Pantallas

Pestañas Productos, Herramientas y Resumen. La barra de arriba tiene buscador y botones que cambian según la pestaña.

Comportamiento

  • Lista completa, sin corte a 50 filas. Antes cortaba con "usá el buscador" y eso obligaba a confiar en que debajo no había nada durante una auditoría. Las fotos cargan perezosas (loading="lazy"), que era lo que costaba.
  • Orden por defecto: valor de inventario (stock total por precio) de mayor a menor. Los encabezados ordenan por unidades o por valor; tercer clic vuelve al orden normal. Por encima de cualquier orden: activos, luego inactivos con unidades, luego inactivos en cero.
  • Botones de estado con la plata de cada estado calculada sobre todo el catálogo (no sobre lo filtrado). Botón "Nuevos": creados en los últimos 30 días.
  • Columnas: stock total (cuadrito verde, ámbar si está en el mínimo o si el producto está apagado, rojo si es cero), existencia en la bodega general (gris), límite con el selector MÁX o MÍN (morado, editable en línea), lote de compra (editable en línea), código de barras (modal protegido con candado), foto (recortador cuadrado con cámara).
  • Edición en línea del mínimo: Enter o salir guarda, Escape cancela, no se guarda mientras se escribe ("el 1 de 15 es un número válido"), vacío o texto raro deja lo que había.
  • Prender o apagar el producto desde la fila, eligiendo una de las dos opciones (no un "¿seguro?").
  • Escaneo de código con pistola (actúa como teclado, con o sin Enter) o con la cámara del teléfono (API BarcodeDetector; en iPhone cae a ZXing por CDN).
  • Generador de Code 128 en SVG sin librerías.
  • Resumen: totales por categoría, aviso de productos con categoría distinta de Insumos Gastables, detector de nombres casi iguales entre productos y herramientas, y un JSON copiable con todo el catálogo y su stock.
  • Quién ve los controles de administración se decide por el permiso de Equipo (empleados), no por una lista de usernames escrita a mano (antes quien entraba no los veía y quien salía los seguía viendo).

Herramientas (herramientas)

Campos nombre, codigo (generado automáticamente), precioUnitario (entero), activo. Mismas reglas de borrado que productos.


Gestión de bodegas (admin_inventario_bodegas)

  • Lista agrupada por tipo, con las dadas de baja al final. Las de vehículo no se listan.
  • Tarjetas de "Valor del inventario" que son botones de filtro por tipo. Herramientas no tiene cifra: por decisión de gerencia no es existencia que se venda.
  • Alta de bodega: rutero, especial y herramientas de empleado no se crean a mano (nacen con el empleado); hay una casilla de excepción para alguien fuera de Fumigación.
  • Campos editables: nombre, tipo, empleado, vehículo, clasificacionRutero.
  • Dar de baja solo en cero: si no, arrastra saldo fantasma a los reportes y nadie lo puede sacar porque deja de ser elegible.
  • "Vaciar": manda todo el saldo de productos a una cuenta de gasto y deja la bodega inactiva. Las herramientas no se gastan: se devuelven.
  • Sección de bodegas derivadas del empleado: diagnóstico en memoria (cero lecturas extra), dry run de crear, adoptar, normalizar y renombrar, línea base de saldos antes de aplicar para comprobar que ninguno se movió, y un botón que renombra todas las bodegas de empleado a la convención.

Valor del inventario (js/valor-inventario.js)

valor = 0
para cada fila de stock en bodegas ACTIVAS:
    si bodega.tipo == "herramientas": saltar (equipo de trabajo, no activo vendible)
    si la fila no tiene productoId: saltar (es herramienta)
    valor += stockActual * producto.precioUnitario
desglose por tipo de bodega: principal, especial, rutero, apv, otra
las cuentas de gasto no entran porque viven en otra colección (ya salieron del inventario)

Transferencias, compras y reversiones (admin_inventario_transferencias, js/transferencia.js, js/compra-factura.js)

Transferencia o gasto (js/transferencia.js)

Un solo formulario que se abre desde Transferencias, Guachimán (con la bodega que se está contando como origen) y Despacho.

abrir(origenPreseleccionado):
    lista vacía cada vez (dejar la lista anterior es la forma más fácil de mover dos veces lo mismo)
    destinos: bodegas activas (sin las de vehículo) y cuentas de gasto, en dos grupos
    productos: activos primero; inactivos al final y rotulados (sacar lo que queda es justo para lo que sirve)
agregar línea: cantidad entera obligatoria
registrar:
    releer el saldo del origen JUSTO antes de validar, sumando todos los registros del par
    para cada línea:
        tipo = "gasto" si el destino es una cuenta de gasto, si no "transferencia"
        procesarMovimientoInventario({tipo, productoId, origen, destino, cantidad,
                                      precioUnitario: precio de catálogo, total})
    en pantalla quedan solo las líneas que fallaron (borrar todo duplicaba al reintentar)

Compra con factura (js/compra-factura.js)

Una factura, varias líneas. Arriba lo que vale para toda la factura (número, monto del papel, bodega destino); en medio las líneas; abajo el cuadre.

Regla Valor
IVA 13 %
Precio de catálogo Se guarda CON IVA. En pantalla se teclea el NETO de la factura.
Del neto al total iva = redondear2(neto * 0,13), total = neto + iva (suma de redondeados, para que los tres números cuadren)
Del total al neto neto = redondear2(total / 1,13), iva = total - neto (restando, para no inventar un céntimo de diferencia en líneas que nadie tocó)
Tolerancia de cuadre 500 colones entre el monto digitado del papel y la suma de líneas. Más que eso no se registra.
Precio cero en catálogo Cuenta como "sin precio": no se compara ni suma a la diferencia acumulada.
Bodega por defecto La que pidió quien abrió el modal, si no la principal de compras.
Origen del movimiento El texto fijo "Compra". El proveedor ya no se pregunta: el rastro queda por número de factura.
registrarFactura():
    si |montoDigitado - suma(cantidad * precioTotal)| > 500: no registrar
    para cada línea (una por una, no todo o nada):
        procesarMovimientoInventario({tipo: compra, origen: "Compra", destino,
            cantidad, precioUnitario: total con IVA, total: cantidad*total,
            precioNeto, iva, totalNeto, totalIva, numeroFactura, montoFacturaDigitado})
        si el precio cambió respecto del catálogo:
            actualizar productos.precioUnitario (DESPUÉS del movimiento, aparte:
            si falla, la compra quedó y solo el catálogo sigue viejo)
    reportar cuáles líneas pasaron; dejar en pantalla solo las fallidas

El mismo producto dos veces en una factura no se suma solo: puede venir en dos renglones con precios distintos. La pantalla muestra en vivo "esta factura sale X MÁS CARA" frente a los precios guardados.

Reversión de un gasto o una compra

Se elige un movimiento reciente (muestra de 100) de tipo gasto o compra.

si ya existe un movimiento con movimientoOriginalId == elegido: rechazar (cada clic inventaría stock)
si original es compra:
    nuevo = {tipo: devolucion_compra, origen: bodega donde entró, destino: null,
             destinoNombre: "Devolución al proveedor"}
si original es gasto:
    nuevo = {tipo: reversion, origen: null, destino: bodega elegida}
    (el origen es un campo de BODEGA: meterle el id del movimiento ensuciaba la auditoría)
procesarMovimientoInventario(nuevo con movimientoOriginalId, tipoOriginal)
marcar el original: revertido=true, revertidoEn, revertidoPor

Historial de transacciones (admin_inventario_transacciones)

  • Por defecto los últimos 3 días (hoy, ayer, anteayer) por fecha contable; si hay movimientos con fecha futura (alistos anticipados), el rango se extiende hasta el último.
  • Filtros por bodega, tipo, producto, origen, destino y usuario sobre el texto que se ve.
  • Resumen de entradas y salidas: con filtro de bodega, entrada es destino == bodega y salida es origen == bodega; sin filtro (visión global), solo compra, ajuste de entrada y reversión suman, solo gasto y ajuste de salida restan, y las transferencias se anulan entre sí.
  • Muestra fecha contable y fecha de registro.
  • Editar y borrar solo con el permiso de gerencia (empleados), no con administrador_bodega (que tiene todo el que abre la pantalla, "con esa clave la restricción desaparecía").
  • La edición no cambia producto, tipo, cantidad, origen ni destino (cambiarlos sin tocar saldos descuadra): eso se hace en Auditoría de Stock. Edita precio, total, motivo, observaciones y fecha, y marca editadoSinAjustarSaldo solo si de verdad cambió algo que mueve saldo.
  • Borrar = revertir en una transacción (antes cada reversión iba en su try/catch y si fallaba el movimiento se borraba igual). Si el movimiento trae marcas de ya ajustado, ofrece "borrar solo el registro".

Saldos, reportes, anomalías y tablero

Pantalla Qué muestra Detalle
admin_inventario_saldos_pivot Matriz producto por bodega Filtro por tipo de bodega y selección múltiple. Oculta filas y columnas en cero. Color por posición relativa en la fila (rojo bajo, amarillo medio, verde alto). Suma registros duplicados.
admin_inventario_reportes Saldos actuales y gastos por día Productos en filas, fechas en columnas, por bodega de origen. Excluye anulados. Exporta a PDF con autoTable.
admin_inventario_anomalias Saldos negativos Ordenados del más negativo. Un negativo es un error de carga.
admin_inventario_dashboard Métricas Valor total, productos, bodegas, buscador rápido (50 resultados).
reporte_gasto_inventario Gasto del inventario personal (rutero) Ver sección de gasto.

Bodeguita: la bodega personal del técnico (bodeguita.html, bodeguitasecure.js)

Propósito

Que cada técnico vea lo que tiene en sus bodegas, declare lo que gastó, liquide los especiales de cada día y devuelva herramientas, sin poder tocar bodegas ajenas.

Quién

Técnicos (operario_bodega). También la abre la app de ruta con ?tab= para ir directo a una sección.

Pantallas

Fila de iconos: Personal, Especial, EPP, Herramientas, Techo (estos tres de js/dotacion.js). Debajo: inventario personal combinado, especiales netos por día, herramientas devolvibles, gastos por auditar y auditados (30 días), transacciones de las últimas 24 horas.

Carga

fase 1 (en paralelo): bodegas del empleado (rutero, especial, herramientas; sin dadas de baja ni vehículos),
                       productos, cuentas de gasto
miBodeguita = elección determinista (rutero antes que especial)
fase 2 (en paralelo): stock de cada bodega propia
fase 3: resúmenes y secciones secundarias

Especiales netos por día

Solo se muestra la última semana; se piden los movimientos desde 9 días atrás (antes se bajaba la colección completa, unos 13 mil documentos por carga).

para cada movimiento que toca una bodega especial del empleado:
    entrada = destino es especial y tipo en {transferencia, compra}
    salida  = origen es especial y tipo en {gasto, transferencia} o (reversion de gasto)
    dia = día operacional del campo fecha (5:00 a 4:59, igual que Arqueo)
    clave = producto + bodega + día
    si entrada: entradas += cantidad
    si salida y es reversion de gasto: salidas -= cantidad
    si salida normal: salidas += cantidad
    saldo = entradas - salidas
mostrar días con saldo != 0; los neteados (saldo 0) quedan ocultos tras un botón
descartar días con más de 7 días de antigüedad

Liquidar un día de especiales

Toda la fila abre el modal (acertar a un botón de 28 px en el teléfono "es una lotería").

si saldo del día < 0: no liquidar (generaría otra salida y agrandaría el hueco: corregir el movimiento)
el técnico DEBE elegir gasto o devolución (no hay tipo preseleccionado)
cantidad <= saldo del día; botón "Todo" para el caso común
fechaContable = 23:00 GMT-6 del día liquidado
si gasto:
    recibo obligatorio (entero > 0)
    cuenta = la cuenta activa cuyo nombre contiene "gasto natural" (si no existe: error)
    procesarMovimientoInventario({tipo: gasto, origen: especial, destino: cuenta,
        cantidad, fecha: fechaContable, motivo: "Liquidación desde operario", recibo, revisado: false})
si devolución:
    principal = bodega principal DE PRODUCTOS (la marcada central, si no la primera que no sea de herramientas;
                null antes que adivinar)
    procesarMovimientoInventario({tipo: transferencia, origen: especial, destino: principal,
        cantidad, fecha: fechaContable, motivo: "Devolución desde operario", empleado})
candado contra doble toque en cada botón

Gasto del kit personal

cantidad > 0 y <= disponible
cuenta debe existir en cuentas_gasto (antes el select tenía ids escritos a mano que no existían)
la salida es SIEMPRE de la bodega rutero del que opera (antes tomaba la primera bodega con saldo
    y el descuento podía caerle a la especial)
movimientoId = procesarMovimientoInventario({tipo: gasto, origen: rutero, destino: cuenta,
    motivo: "Gasto de rutero", revisado: false, ...})
crear gastos/{id}: {productoId, bodegaId, cantidadGastada, cuentaGastoId, empleadoId,
    fechaGasto, estado: "pendiente_auditoria", tipoGasto: "operativo", movimientoId}
(primero el movimiento: si falla, no queda un gasto huérfano en "por auditar")

Desde una bodega especial no se permite el gasto simple: se liquida por día.

Otras acciones

  • Deshacer una transacción propia dentro de los 30 minutos (según createdAt): devuelve saldos y anula en una transacción.
  • Reportar pérdida de herramienta: crea un registro en gastos pendiente de aprobación y NO rebaja stock todavía.
  • Devolver herramienta desde una bodega especial a la central de herramientas (resuelta por esCentral, no por "cualquier bodega de tipo herramientas", que con bodegas por empleado devolvía a la de un técnico).

Despacho del lado del inventario (despacho.html, despacho.js, despacho-catalogos.js)

Propósito

Tablero del día por ruta: qué necesita cada ruta según sus citas, qué ya se le alistó, qué falta, qué sobró, cómo cerró. El alisto en bloque reemplazó a la página Alisto (retirada el 26-ago-2026). Las pestañas Servicios y Herramientas son los catálogos de receta y de herramientas de alisto.

Lecturas

citas del fecha_dia, ruteador del día (y del anterior), empleados, bodegas, servicios, productos, herramientas, movimientos_inventario del día, stock_bodegas de la principal y de las especiales, saldos_minimos, configuracion/rutas (turnos fijos), work_sessions (turnos abiertos).

Requerido por ruta

Por cada cita del día (excluye ficticias y la ruta de canceladas): acumula productos con requerido_de_cita, herramientas (de metadata.equipo y de herramientasAsociadas del servicio; una herramienta del mismo tipo por ruta basta; se comparan sin tildes ni mayúsculas), certificados y cobros con datáfono.

Lo ya alistado por ruta

Los especiales son POR DÍA: cuenta solo lo cargado a la bodega especial de los técnicos de esa ruta en ESE día operacional. Antes se miraba el saldo de la bodega, que arrastra días anteriores, y una ruta salía como alistada "solo porque al técnico le quedaban unas de antes".

especialesPorEmpleado[username].porProducto[item] = {
    cantidad: lo que ENTRÓ hoy a su bodega especial (no se deshace si después se gasta o devuelve),
    neto: entradas - salidas del día
}
se cuentan TODAS las cargas del día, no solo esPedidoEspecial (también envíos puntuales y liquidaciones
    del saldo de otro rutero)
se excluyen anulados
un traslado a la especial de OTRO técnico no se suma de vuelta (dos rutas no pueden acreditarse el mismo alisto)
una devolución de "no recibido" o una corrección de alisto sí deshacen
el empleadoId de la bodega se resuelve por username o por id de ficha

faltantesDeAlisto(ruta):
    si no se pudo leer lo alistado: decir "no se sabe", nunca "falta todo"
    si la fecha es anterior a hoy: cerrado, no se pide nada
    para cada item requerido: si alistado + 0,001 < necesita: falta = necesita - alistado

Alisto en bloque

planDeAlisto():
    para cada ruta con citas (salvo "Sin asignar", rurales que alista el socio, y fuera del filtro de turno):
        faltan = faltantesDeAlisto(ruta)
        destino = bodega especial activa del primer técnico de la ruta que tenga una
        (si la ruta no tiene técnico, la bodega transitoria, que se crea sola si no existe)
    ordenar rutas por número
    repartir la existencia de la principal entre rutas en ese orden:
        enviar = min(falta, restante_en_principal); restante -= enviar

generarAlisto():
    releer especiales y stock de la principal JUSTO antes (otra persona pudo alistar)
    rehacer el plan; si cambió algo respecto del mostrado: repintar y pedir revisar
    confirmar "Se generan N transferencias; M líneas quedan fuera por falta de existencia"
    fechaContable = mediodía del día de ruta
    para cada línea con enviar > 0:
        procesarMovimientoInventario({tipo: transferencia, origen: principal, destino: especial,
            cantidad: enviar, motivo: "Pedido Especial", esPedidoEspecial: true,
            origenPantalla: "despacho", rutaOperativa: ruta, observaciones: "Alisto desde despacho"})
    guardar la hoja imprimible SOLO si algo salió (antes quedaba la hoja de un alisto que nunca ocurrió)

Correcciones desde la tarjeta de ruta

Acción Movimiento
"+" cargar un producto suelto transferencia principal a especial, cargaManual: true, esPedidoEspecial: true (o "Carga de vehículo")
"-" quitar de una cita Primero rebaja metadata.productos de la cita o agrega a metadata.excluidos (se puede deshacer), después transferencia especial a principal con motivo: 'Devolución por eliminación en cita', quitadoDesdeDespacho: true. Si la devolución falla, la cita se revierte.
Cuánto devolver al quitar min(qty, alistado - (pide - qty), saldo en bodega): si la ruta ya venía corta, esas unidades nunca se alistaron y no hay nada que devolver
No recibido Devolución o pérdida con noRecibidoEnRuta: true
Reasignación de técnico "Mover alisto": transferencia de la especial vieja a la del técnico que quedó liderando, misma fecha contable, esPedidoEspecial: true
Bodega transitoria Botón para repartir a mano lo que quedó ahí (a un técnico, a gasto o a la principal)

Marcas en la tira de días: un día se pinta en rojo si tiene devoluciones sin revisar contra el físico o alisto varado en la transitoria.

Barra de liquidación de la ruta

Tres estados (liquidada, pendiente, no se sabe), por componente: cobros (citas sin método de pago), especiales (lo que le queda al técnico DE LO DE HOY, topado con el saldo real; no se mira el saldo entero porque ahí ya está el alisto de mañana, que se carga la tarde anterior), vehículo (fuera de uso) e inventario personal (individual: lo cierra cada uno). Un día pasado se muestra con el histórico guardado, no con los saldos de ahora.

Guardias en la agenda

  • js/agenda-reasignar-especiales.js: se engancha en la función que guarda el ruteador (todos los caminos que cambian técnicos pasan por ahí). Si el portador de lo alistado deja la ruta, lo alistado ese día se traslada a quien quede en esa ruta (o a un delegado o a la principal). El inventario es de la RUTA, no del técnico. También devuelve lo que esperaba en la transitoria cuando la ruta recibe técnico.
  • js/agenda-aviso-especiales-cita.js: cuando se mueve una CITA que ya tenía especiales alistados a otra ruta, NO mueve inventario (quien mueve citas es de ventas); avisa, deja el mensaje listo para bodega y una nota en el ruteador que despacho pinta. Nació de un caso real: se alistó a una ruta y tres minutos después movieron la cita a otra.

Inventario personal desde despacho (js/inventario-tecnico.js)

Al lado del nombre de cada técnico: ver su bodega personal con su kit (saldos mínimos), cargarle producto o tirarlo a gasto, por el mismo motor.


Pedidos especiales (admin_inventario_pedidos_especiales)

Entregar insumos puntuales a la bodega especial de un empleado para una fecha (por defecto mañana, botones de día con su nombre).

cargar bodegas tipo especial activas, principal de productos (tipo principal) y central de herramientas (esCentral)
el pedido se arma en tarjetas por bodega; cada línea es producto o herramienta
validar POR TOTAL de cada ítem contra la principal (dos líneas de 10 con saldo 15 pasaban una por una)
releer saldo fresco al confirmar y otra vez al procesar
candado contra doble toque
fecha = 12:00 local del día elegido
para cada línea: transferencia principal (o central de herramientas) a especial, esPedidoEspecial: true
una tarjeta sin bodega se reporta (antes se descartaba en silencio)

Arqueo de Inventario (arqueo_inventario.html, arqueo_inventario.js, js/arqueo-auditoria-liquidaciones.js)

Propósito

Cerrar cada día de especiales (que nada quede en la bodega especial sin liquidar), ver la matriz de saldo de las bodegas personales contra su saldo meta, reponer en masa y auditar las salidas de las bodegas de campo.

Pestañas

Especiales (por día de alisto) y Personales (saldo actual por turno: diurno, nocturno, termitero, gerencia, rural). La pestaña de vehículos se eliminó. Tira de 5 días (2 atrás, hoy, 2 adelante) con botones Ayer, Hoy, Mañana, y días rojos con saldo abierto.

Días con pendientes

Escanea 30 días atrás y 30 adelante (los alistos llevan la fecha del día en que se usan, así que lo preparado para mañana ya tiene saldo hoy). Marca un día si alguna bodega tiene saldo distinto de cero ese día (un negativo es tan anómalo como un pendiente). Una transferencia sin una de sus patas no puede cuadrarse y se ignora, con el mismo criterio que la matriz.

Matriz de especiales

para los días seleccionados (ventana desde 5:00 del primero hasta 4:59 del día siguiente al último):
    para cada movimiento no anulado que toca una bodega especial visible:
        entrada si destino es la bodega; salida si origen es la bodega
    celda[producto][bodega] = entradas - salidas
solo se liquida un pendiente POSITIVO y con UN solo día en pantalla
    (con varios días sumados no se sabe a qué día imputarlo y deja fantasmas)
un negativo no se liquida: se pinta en rojo y se manda a Auditoría de Stock

Liquidar una celda

Modal con tres tipos en botones (gasto, devolución, transferencia) y varias líneas que deben sumar el pendiente. Gasto usa "Gasto Natural" por defecto. Devolución va a la principal de productos o de herramientas. Transferencia solo a bodegas del mismo grupo que el origen. Se avisa si el stock real de la bodega es menor que el pendiente (el motor no dejará sacar). La fecha contable es la del pedido original (si no se encuentra, se detiene: registrar con hoy falsea la contabilidad). Las líneas se aplican una por una y el error dice cuáles sí quedaron.

Saldo meta y reposición masiva

minimoDe(bodega, producto):
    grupo = bodega.clasificacionRutero (solo tipo rutero; vehículo = sin grupo)
    m = saldos_minimos[grupo__producto]; si no existe: null (el grupo no controla ese producto)
    devolver max(0, m.minimo + bodega.ajustesSaldoMinimo[producto])

reposición:
    releer saldos (la pantalla pudo estar horas abierta)
    faltante por bodega y producto = minimoDe - saldo, si > 0
    matriz producto por bodega; tocar celda, fila o columna la saca o mete del plan
    mostrar cuánto tiene la principal de cada producto
    generar: una transferencia por celda marcada, motivo "Reposición de saldo meta"
    guardar ordenes_reposicion {grupo, vista, turno, origen, bodegas, movimientos, unidades,
        lineas[] con movimientoId, fallidas[], creadaPor} aunque algo haya fallado
    imprimir la orden para ejecutarla físicamente

Envío puntual

Desde la principal a cualquier bodega ("mandale dos frascos a fulano"). En Especiales se imputa al día seleccionado a mediodía; en las otras pestañas al momento actual. Acepta lector de código de barras (busca por código de barras, código interno o id). Avisa si el producto no está en el catálogo de mínimos del grupo de destino.

Bodegas fuera de la tabla

Las de empleados inactivos o dadas de baja que todavía tienen saldo se listan aparte: ese stock existe, no aparece en ninguna pantalla y nadie lo puede liquidar si no se ve.

Auditoría de liquidaciones

Reemplazó al "historial de 24 horas". Lista solo lo que hay que auditar: lo que salió de (o entró a) una bodega de campo, porque "es donde la empresa pierde plata si nadie revisa".

Pregunta por línea Herramienta
Si fue gasto: ¿el trabajo del día justifica ese consumo? Botón con las citas del día del técnico dueño de la bodega (según ruteador) y aviso de canceladas que llevaban producto
Si fue devolución: ¿el producto está físicamente de vuelta? Marca de revisado contra el físico

Fechas: especiales por día contable del campo fecha; personales por día operacional. Un traspaso entre dos bodegas de campo se lista una sola vez, del lado del que entrega. Dar por buena una línea es un clic (auditoriaEstado, revisado); anotar una inconsistencia es el camino largo, a propósito. Borrar una transacción pregunta dos veces (qué se borra y que no hay deshacer) y usa la reversión del motor.


Saldos Mínimos (saldos_minimos.html, saldos-minimos.js)

Propósito

Definir cuánto debe cargar siempre cada grupo de bodegas personales (el kit) y ejecutar la reposición física.

Datos

Documento Contenido
saldos_minimos/{grupo}__{productoId} { grupo, productoId, minimo }. Meta 0 es válida y significa "no debe quedar nada".
bodegas.clasificacionRutero diurno, nocturno, termitero, gerencia, rural.
bodegas.ajustesSaldoMinimo { productoId: delta }. Sin tope; el único límite es que el objetivo no baje de 0.

Grupos: Rutero Diurno, Rutero Nocturno, Rutero Termitero, Gerencia, Rutero Rural (vehículos fuera de uso). Los grupos son independientes: un producto puede estar en uno y no en otro.

Pantallas

  • Clasificador de bodegas por turno, ordenadas por horas trabajadas en los últimos 7 días (de work_sessions, contando turnos abiertos hasta ahora). Tocar el turno activo lo quita. Dar de baja solo en cero.
  • Matriz compacta producto por bodega con el mínimo efectivo; el delta se aplica en la celda (en memoria antes de guardar, para que dos toques seguidos no lean el mismo valor viejo).
  • Hoja de reposición: pantalla de trabajo, no PDF.
calcularReposicion():
    principal = tipo principal cuyo nombre no menciona herramientas
    para cada grupo, para cada bodega del grupo, para cada mínimo del grupo:
        debe = max(0, minimo + ajuste)
        tiene = saldo actual
        dif = debe - tiene
        si dif > 0: entregar dif; si dif < 0: retirar -dif
    totales por producto: cuánto sacar de la principal para toda la vuelta
el usuario marca (todo el grupo, una bodega o una línea) y confirma que YA lo movió
cada línea marcada: transferencia, motivo "Reposición de saldo mínimo"
deshacer: movimiento inverso apuntando al original, motivo "Corrección: la reposición se registró sin haberse movido"
historial de 3 días por origenPantalla (índice origenPantalla + fecha desc); el avance cuenta solo el día de hoy
texto plano para mandarle a un técnico lo que le toca a su bodega

Auditoría de Stock (auditoria_stock.html, auditoria_stocksecure.js)

Propósito

Verificar que el saldo guardado cuadra con la suma de movimientos, y corregir el que esté mal: el historial o el saldo.

Cálculo

cargar TODOS los movimientos, todo el stock, productos, herramientas y bodegas (sin límite, a propósito)
índices en memoria por ítem (sin ellos era ítems x bodegas x movimientos y congelaba la pestaña)
para cada ítem (productos y herramientas, con clave compuesta tipo+id) y cada bodega:
    registrado = suma de stockActual de todos los registros del par
    calculado = suma(cantidad si destino==bodega) - suma(cantidad si origen==bodega), sin anulados
    si no hay movimientos y registrado == 0: omitir
    si no hay movimientos y |registrado| > 0,01: estado "sin movimientos"
    si no, si |registrado - calculado| > 0,01: estado "discrepancia" (mayor o menor)
    si no: "correcto"

Filtros por tipo de bodega, tipo de discrepancia y texto. Exporta CSV con BOM (para que Excel lea acentos) y escapa celdas que empiezan con =, + o @. El detalle muestra fecha contable y fecha de registro: cuando no coinciden, explica muchos descuadres.

Corrección manual de un movimiento

Cambia solo el documento del movimiento (tipo, cantidad, origen, destino, fecha, anulado); el saldo guardado no se toca. Cada corrección guarda los valores anteriores en historialCorrecciones, corregidoEn y corregidoPor. El tipo debe ser uno que el motor sepa aplicar. Cantidad 0 es válida (deja el movimiento sin efecto conservando el registro). Antes de guardar se muestra cómo queda el calculado. Si cambia origen o destino se recalcula el ítem entero.

Ajuste manual del saldo

transacción:
    releer todos los registros del par
    si el total cambió desde que se abrió la pantalla: error OTRO_CAMBIO (no pisar un despacho hecho mientras tanto)
    si no hay registro: crear con id bodega__item
    si no: principal = nuevo, duplicados = 0
    ajustadoManualmenteEn, ajustadoManualmentePor, historialAjustes += {por, cuando, antes, despues, motivo}
no crea movimiento: el calculado queda como referencia

Redondeo de saldos con decimales

Busca en todas las bodegas los registros con fracción y los deja enteros, cada uno firmado como un ajuste. Muestra antes qué va a cambiar, relee dentro de la transacción y redondea cada registro por separado.


Auditoría de transacciones por día (admin_inventario_auditoria, administrador_bodegasecure_auditoria.js)

  • Tarjetas de los últimos 35 días con color: gris sin transacciones, rojo si faltan por auditar, verde completo.
  • Transacciones del día agrupadas por usuario; cada una se marca correcta o con observación (auditoria, auditado, auditoriaCorrecta, detalleAuditoria, auditadoPor, fechaAuditoria).
  • Los anulados se listan apagados y rotulados.
  • Hay un botón de auditoría en bloque para las transacciones de un usuario específico de bodega cuyo nombre está escrito en el código (deuda técnica: debería ser por permiso).

Guachimán: conteo físico de una bodega (guachiman.html, js/guachiman-*.js, functions/auditoria-guachiman.js)

Propósito

Recorrer una bodega con la tableta o el teléfono, producto por producto, contar lo que hay y dejar el sistema igual que el físico. Desde la misma pantalla se compra, se transfiere, se arma la orden de compra al proveedor y se imprimen rótulos.

Quién

guachiman (bodega). El reporte lo ve cualquiera que entre.

Pantalla

  • Botón grande con la bodega que se mira; abre una hoja con las bodegas agrupadas por tipo. La principal de productos es la de defecto.
  • Productos o herramientas, nunca mezclados. En una bodega de herramientas se mira por categoría de dotación (EPP por defecto, Techo, Herramientas).
  • Tarjetas con foto grande, nombre y cantidad. La lista lleva TODO el catálogo: lo que está en cero no se pinta por defecto pero aparece buscándolo y con el filtro "Sin saldo" (contando uno se topa con cosas que el sistema dice que no hay).
  • Semáforo de la última revisión: verde hoy, amarillo dentro de los últimos 7 días operacionales, rojo más viejo o nunca. "Contado" quiere decir contado HOY: antes la marca era para siempre y un conteo de hacía tres semanas seguía diciendo "completo".
  • Filtro de rotación: un producto rota si entró comprado a la principal o salió gastado en el último mes (se leen seis meses de movimientos, solo cuando se pide). Lo que se hace en Guachimán no cuenta como rotación: "la auditoría mide, no mueve".
  • Lápiz para corregir nombre, precio y mínimo desde la bodega, en la misma ficha que lee el catálogo. Cámara para la foto. Botón para prender o apagar el producto.
  • Indicador por bodega de la auditoría de hoy: verde si se revisó todo lo que tiene saldo, amarillo una parte, rojo nada, morado si hoy no está completa pero en la semana (lunes a domingo) sí.

Auditoría de principio a fin (js/guachiman-auditoria.js)

Desde el 21-oct-2026 no se cuenta ni se corrige nada sin una auditoría abierta de esa bodega, y una auditoría es de UNA bodega.

abrir auditoría (bodega)
repetir: tocar un producto o "Siguiente"
    paso 1: Completo / Faltante / Sobrante
    si completo: registrar y seguir
    paso 2: digitar lo contado; dif = limpiar(contado - sistema)
        se ELIGE la acción y se guarda con otro botón
        si dif tiene decimales: solo ajuste_salida / ajuste_entrada (es residuo de cuando se aceptaban fracciones)
        si faltante: gasto contra una cuenta (la natural primero, o pérdida, etc.)
        si sobrante: compra hacia la principal (la bodega auditada no cambia: el sistema nunca le contó esas unidades)
        no hay escape: solo se cierra sin mover si la cuenta coincide
    registrar:
        auditorias_bodega += {bodegaId, productoId, tipoItem, estado, cantidadSistema (ANTES del movimiento),
                              cantidadContada, diferencia, accion, movimientoIds[], usuario}
        auditorias_bodega_estado/{bodega}.items[clave] = {estado, cuando, usuario, cantidadSistema, cantidadContada}
notas de orden y limpieza en cualquier momento: foto opcional + comentario escrito o dictado
    (el dictado se corrige con IA: ortografía y nombres de productos del inventario)
finalizar: lista lo que falta con nombre; guarda auditorias_sesiones con el resultado por producto
la sesión abierta se guarda en el navegador por si se cierra la página

Aviso al cerrar (auditoriaGuachimanAviso)

Endpoint HTTP autenticado (permiso guachiman, administrador_bodega o gerencia). Lee auditorias_sesiones/{id}, arma un texto corto (quién auditó, responsable, completos, faltantes, sobrantes, sin revisar, orden y limpieza, sin montos; si todo cuadró, felicitación) y una imagen con cada producto (foto, estado y en cuánto quedó) que arma el servidor porque las fotos de Storage no se dejan dibujar en el navegador. Lo encola en la salida de WhatsApp del bot de avisos. Destino por tipo de bodega: principal al grupo personal de la responsable de bodega; rutero, especial y herramientas al grupo personal del dueño. Si no encuentra el grupo, va a un número de prueba con el aviso "No encontré la comunidad de la bodega X". Una configuración (alertas_config/guachiman_auditoria) puede forzar todo a un solo destino para pruebas.

Orden de compra (js/guachiman-pedido.js)

El papel NO lleva precios (el proveedor cotiza); los precios del catálogo se ven solo en un panel aparte para estimar. No mueve inventario. El borrador vive en el navegador.

tengo(p) = existencia en la BODEGA GENERAL (no la suma de todas)
enCamino(p) = pedido en órdenes guardadas de los últimos 30 días
              - comprado (movimientos compra) desde la primera de esas órdenes
              (registrar la factura descuenta solo; nadie marca la orden como recibida)
lote(p) = unidadesCompra si > 0, si no el factor de presentación del proveedor
factor(p) = unidades del proveedor por unidad nuestra ("1 del proveedor = N nuestras", en la ficha)

si tipoLimite == "min":
    debeComprar = tengo <= minimo
    sugerido = lote
si no (máximo):
    falta = maximo - tengo
    sugerido = floor(falta / factor) * factor     (cajas enteras: si caben 50 y la caja es de 24, 48)
    debeComprar = maximo > 0 y sugerido >= lote
    (ejemplo del código: máximo 480, compra 120: con 400 no toca, con 360 sí)
orden nueva = todos los productos activos con debeComprar, cada uno con su lote
la cantidad pedida se redondea hacia arriba a cajas enteras del proveedor
"Quedaría" = tengo + enCamino + pedido, en ámbar si pasa del máximo o no llega al mínimo
número de orden: AAAAMMDD-letra (A, B, ... Z, AA, AB), sacado de un contador en transacción
salida: PDF tamaño carta o imagen para pegar en un chat

Rótulos y reporte

js/guachiman-rotulador.js imprime rótulos adhesivos con logo en hoja carta. js/guachiman-reporte.js arma el papel del día de la bodega a partir de auditorias_bodega ("realizada a" el dueño de la bodega, no a "Bodega 7").


Herramientas

Hay tres mundos de herramientas, y el documento de arquitectura interno lo señala como fragmentación a resolver:

Colección Uso Relación con el stock
herramientas Herramientas con saldo por bodega, movidas por el motor con tipoItem: 'herramienta' Sí, en stock_bodegas
herramientas_alisto Equipos que se asignan a citas, con saldoTotal global No; disponibilidad calculada
herramientas_catalogo + asignaciones + reportes Asignación a personas y vehículos con verificación por foto No

Herramientas de alisto en la agenda

disponible(h, grupoRuta, cita) = max(0, h.saldoTotal
                                     - usos de h.nombre en citas del día del mismo grupo de rutas (excluye esta cita)
                                     - veces que ya se eligió en este formulario)
grupoRuta: diurnas (Ruta 1 a 10) o nocturnas (Ruta 11 a 20)

La cita guarda el NOMBRE en metadata.equipo[]. Valores de texto libre viejos se muestran con la etiqueta "(legacy)". alisto-herramientas-audit.html normaliza nombres de citas contra el catálogo para limpiar esa deuda.

Movimientos de herramientas (admin_inventario_herramientas_movimientos)

Compra (con opción de actualizar el precio), transferencia entre bodegas de tipo herramientas y gasto (con recibo), y su historial (filtra por tipoItem == 'herramienta' o por tener herramientaId sin productoId).

Asignación y verificación (herramientas_admin.html, herramientas.html)

Documento Campos
herramientas_asignaciones tipo (empleado o vehiculo), asignatarioId, asignatarioNombre, herramientaId, herramientaNombre, cantidad, activo (cantidad > 0), fechaAsignacion, asignadoPor, ultimoReporte
herramientas_reportes empleadoId, herramientaId, estado (presente o ausente), fotoUrl, fotoPath, fechaReporte, tipoReporte, vehiculoId, unidadIdx

El técnico verifica unidad por unidad (una asignación de cantidad 3 se expande en 3 pasos), con foto de referencia del catálogo; marca presente con foto o ausente. Gerencia ve el feed de reportes.


Dotación: EPP, Techo y herramientas personales (js/dotacion.js)

Inventario aparte de los insumos, en la bodega de herramientas de cada persona (desde 22-oct-2026). Techo solo lo ve el equipo de techo (categoría Techo en planilla_alias/{usuario}.categoria, más una lista corta adicional).

técnico en Bodeguita ve sus ítems (foto, nombre, cantidad) por categoría
"Sí tengo": verificaciones_dotacion/{bodegaHerramientas}.items[productoId] = {cuando, usuario}
"Solicitar": 1 unidad a gasto ("Gasto Natural") desde su bodega de herramientas
             + solicitudes_dotacion {estado: pendiente, ...}; borra su "Sí tengo"
equivocarse: anula la solicitud si sigue pendiente y deshace el gasto
despacho, "Solicitudes": resumen por producto y tabla por técnico y categoría
    Pagar: marca la solicitud pagada EN TRANSACCIÓN (dos personas no pagan dos veces)
           y transfiere 1 de la bodega general a la bodega de herramientas de la persona
           si la general no tiene, primero entra como compra y luego se transfiere (desde 30-oct-2026)
           si la transferencia falla, la solicitud vuelve a pendiente
    Rechazar: anula la solicitud y deshace el gasto
    lista de compra por categoría = pedido - existencia en la general

revisión mensual (desde 26-oct-2026):
    ventana = primera semana del mes (desde el primer lunes)
    cada línea de EPP y Herramientas debe tener "Sí tengo" desde ese lunes, o estar solicitada
    día límite: sábado para diurnos, viernes para nocturnos
    avisos al abrir ruta, al terminar la última cita y al intentar apagar
    el día límite no deja apagar la app de ruta sin la revisión
    hay fechas puntuales exentas (rutas muy largas)
    fuera de esa semana no hay avisos ni bloqueo

Etiquetas y códigos de bodega (etiquetas_bodegas.html, js/codigo-bodega.js)

Código de barras corto de cada bodega: 7 dígitos que empiezan con 9, calculado con un hash fijo (FNV-1a) del id del documento. No se guarda: quien imprime y quien lee (el traslado rápido) lo recalculan igual. El 9 inicial lo separa de los códigos de producto (11 dígitos con 1 inicial, o EAN de 8 o más). Con unas 50 bodegas la probabilidad de choque es despreciable y la hoja marca si llegara a pasar. Hoja de cuatro columnas recortables; con ?imprimir=1 abre el diálogo de impresión directo.

Recuperar bodegas (administrador_bodega_recuperar)

Recorre todo el historial (sin anulados) y lista los ids de bodega que aparecen en movimientos pero no existen en bodegas; permite recrearlas con ese mismo id (setDoc), eligiendo tipo, empleado o vehículo.

Turno y especiales en la app de horas

Al cerrar turno, registro_horas revisa (sin bloquear) los especiales del día operacional sin liquidar de la bodega especial del empleado, las citas sin método de pago y las citas sin marca de ingreso o salida, y ofrece ir a liquidar. Al iniciar turno en ruta, el técnico confirma su inventario de especiales; si falta algo, se crea un ticket con vencimiento a las 23:59 del día siguiente.


Gasto, cuentas, proveedores y reposición

Cuentas de gasto (admin_inventario_cuentas_gasto)

CRUD de cuentas_gasto con nombre, descripcion, activo, fechaActualizacion, usuarioActualizacion. Elegir una cuenta como destino convierte el movimiento en gasto: el sistema lo distingue porque el destino no es una bodega. La cuenta "Gasto Natural" es la de defecto para todo consumo en servicio y se busca por nombre (que contenga "gasto natural" y esté activa); si no existe, las liquidaciones fallan con un aviso. Hay otras cuentas para pérdida y similares, que usa Guachimán para faltantes.

Proveedores (admin_inventario_proveedores)

CRUD de proveedores con nombre, contacto, telefono, email y auditoría. Hoy la compra ya no pregunta el proveedor: el rastro es el número de factura, y la orden de compra lleva el proveedor como texto.

Revisar gastos (admin_inventario_revisar_gastos)

Últimos 500 movimientos gasto (los anulados se descartan: ya se devolvieron), con producto, cantidad, empleado, bodega de origen, tipo y motivo. Botón "Aprobar" que anota revisado: true, fechaRevision, usuarioRevision en el movimiento. Alimenta las tarjetas "por auditar" y "auditados" de la Bodeguita.

Gastos semanales (admin_inventario_gastos_semanales)

Pivote técnico por semana del gasto de las bodegas rutero.

rango por defecto: últimas 4 semanas
consulta: tipo == "gasto" y fecha en rango, ordenado por fecha desc
    (índice tipo ASC + fecha DESC; antes se bajaba la colección entera, unos 14 mil documentos y 16 MB)
filtrar en memoria: origen es una bodega rutero, fecha válida, no anulado
tecnico = usuarioId o empleado
semana = año + número de semana ISO (lunes a domingo)
total = mov.total o cantidad * precioUnitario
celda[tecnico][semana] += total
fila de totales por semana; exporta a Excel

Gasto del inventario personal (reporte_gasto_inventario.html)

Responde cuánto gasta cada técnico de lo que lleva encima (solo bodegas rutero; no entran vehículos, especiales ni principal).

consulta por rango de fecha (un solo campo, sin índice compuesto) y filtra tipo gasto en memoria
descarta anulados
dia = día operacional; semana = lunes a domingo
plata(mov) = mov.total, si no cantidad * mov.precioUnitario, si no cantidad * precio de catálogo de hoy
    (aproximación declarada: mejor que contar cero y que el renglón desaparezca)
vistas: todas las semanas del período aunque alguna sea cero (un cero es información),
        meses con variación contra el anterior, ranking por técnico con lo de cada mes
gráfica en SVG propio; período por defecto: mes en curso y el anterior completo; viaja en la URL

Reposición: qué comprar (reposicion.html)

Propósito

Decidir el pedido semanal al proveedor a partir de lo que de verdad se consumió, no de los mínimos.

Entradas
Dato Fuente
Consumo Movimientos gasto de los últimos N días (ventana por defecto 28, entre 7 y 180). Las transferencias NO cuentan: mover una caja no gasta nada. Las herramientas de alisto no entran.
Saldo Suma de stockActual en TODAS las bodegas (un producto en una bodega personal se va a usar antes de que llegue el pedido; contar solo la principal haría comprar de más).
Días de cobertura Perilla, por defecto 14 (1 a 120).
Colchón mínimo Perilla, por defecto 20 % (0 a 200).
Fórmula
diasDatos = min(ventana, días desde el gasto más viejo encontrado)   (si hay menos historia, no se diluye)
para cada producto vivo (activo, o inactivo con consumo o con saldo):
    total = suma de cantidades gastadas en la ventana
    diario = total / diasDatos
    promSemanal = total / (diasDatos / 7)
    pico = mayor consumo en una semana de la ventana
    salto = pico / promSemanal          (≈1 si sale parejo; 2 o 3 si sale por trabajos grandes)
    colchon = si total > 0: min(0,9, max(colchonMin, colchonMin + max(0, salto - 1) * 0,4))
              si no: colchonMin
    objetivo = diario * diasCobertura * (1 + colchon)
    pedir = ceil(objetivo - saldo) si es positivo, si no 0
    cobertura = saldo / diario (días)
    seMueveSeguido = tuvo consumo en al menos 4 días distintos
    estado = "urge"   si diario > 0 y cobertura < 7 y seMueveSeguido
             "pronto" si diario > 0 y pedir > 0
             "bien"   si diario > 0
             "quieto" si no hay consumo
    costo = pedir * precio
orden: peso = diario * min(1, max(0, (objetivo - saldo) / objetivo)) si pedir > 0, si no -1
       desempate por costo, por cantidad a pedir y por nombre

Lecciones anotadas: el tope del colchón es 90 % ("más que eso es comprar por miedo, y el pedido es semanal"). "Urge" exige las dos condiciones porque un repuesto que sale una vez al mes y quedó en cero aparecía arriba por tener cero días de cobertura. Ordenar por porcentaje faltante empataba todo lo que estaba en cero, y ordenar por días de hueco daba unos 28 días para cualquier producto sin saldo; los dos dejaban los repuestos sueltos encima de lo que frena la operación. Hay productos marcados inactivos que se siguen gastando: entran con una marca porque "el catálogo es el que está atrasado".

Resumen arriba: productos a pedir, urgentes (menos de 7 días) y costo estimado (avisa cuántos no tienen precio).

Documentos internos estáticos

  • propuesta_reposicion_bodega.html: propuesta del proceso de reposición (antes y después, cómo medir el gasto, qué cambia en los datos). No lee Firestore.
  • arquitectura_inventario.html: mapa de colecciones, tipos de bodega, ciclo de vida y hallazgos (cuatro catálogos de cosas, herramientas por nombre en citas, código de barras sin unicidad garantizada, duplicados heredados en stock). No lee Firestore.
  • admin_inventario_instructivo.html: manual de usuario (compra, transferencia, gasto).

Personal: visión general

Empleados: ficha, cuenta y permisos (empleados.html)

Propósito

Una ficha por persona que es a la vez: identidad (username), acceso (cuenta de Firebase Auth y mapa de permisos), datos de nómina (en un subdocumento privado), dueña de bodegas y referencia para horas, citas, comisiones y gafete. Ser empleado es TENER FICHA, no solo tener un correo del dominio (desde 18-oct-2026; el registro de cuentas de Auth está abierto y no se puede cerrar sin romper el inicio de sesión anónimo de otra parte del sistema).

Quién

Gerencia y Recursos Humanos con la clave empleados (en la interfaz se llama "Equipo").

Pantallas

Lista de empleados con métricas de actividad (horas de 7 y 30 días, última sesión, días activos, bodegas asignadas), modal de ficha con secciones, tabla masiva de accesos (empleados activos en filas, módulos en columnas), sección de vacaciones (ganados, disfrutados, saldo), herramientas de migración y diagnóstico.

Colecciones

Colección Contenido
empleados/{id} Ficha visible por todo el equipo. El id es el username en las fichas nuevas; unas viejas tienen id aleatorio.
empleados/{id}/privado/datos Datos sensibles (desde 19-oct-2026).
empleados_uid_lookup/{uid} { empleadoId }. Une la cuenta de Auth con la ficha. Es lo que las reglas usan para isEmpleado().
empleados_baja_log Bitácora de altas y bajas de acceso.
vacaciones Solicitudes (ver Vacaciones).

Campos de la ficha

Grupo Campos
Identidad username (inmutable), primerNombre, segundoNombre, primerApellido, segundoApellido, nombrePreferido, inicial, aliasRuta
Contacto telefonoEmpresa, correoPersonal, correoEmpresa
Puesto cargo (texto libre), departamento (Fumigación, Ventas, Administración, Bodega, Logística, Recursos Humanos, Contabilidad, Gerencia), fechaIngreso, tipoContrato, estado (Activo, Inactivo, Suspendido, Vacaciones)
Nómina (privado) cedula, fechaNacimiento, telefonoPersonal, direccion, banco, cuentaBancaria, tipoPago (Mensual o Por horas), tarifas_jornada (por Diurna, Mixta, Nocturna: ordinaria y extra), tope_servicios_profesionales, salario, precio_hora_sencilla_por_mes (mapa AAAA-MM), comision_ventas_oficina_pct, comision_ventas_calle_pct, meta_ventas_base
Banderas de nómina jornada_simplificada_sin_extras, vendedor_leads_pauta
Vacaciones diasVacacionesAntes2025 (días disfrutados antes del corte del sistema)
Operación tipoVehiculoPropio, placaVehiculo, licenciaConducir, vencimientoLicencia, certificaciones, especializacion, tallas de uniforme, notas
Documentos cedulaFotoFrente, cedulaFotoDorso, licenciaFotoFrente, licenciaFotoDorso, fotoPerfil
Acceso permisos (mapa), firebaseAuthUID, firebaseAuthEmail, codigoVersion (versión del gafete)

Durante la transición los campos sensibles se escriben en los dos lugares (ficha y privado/datos con mergeFields), y planilla y liquidación leen el privado con la ficha como respaldo. CAMPOS_FICHA_PRIVADA en js/planilla-core.js es la única lista.

Alta de un empleado

validar username: /^[a-zñ0-9][a-zñ0-9._-]{2,40}$/ y único entre TODOS los empleados (activos e inactivos)
    (vacío = nombre.apellido)
contraseña inicial = palabra común aleatoria + 4 dígitos (no se guarda en Firestore)
setDoc(empleados/{username}, ficha con codigoVersion "v1")
guardarFichaPrivada(username, ficha)
sincronizarBodegasDeEmpleado(ficha)    -> crea rutero, especial y herramientas si es de Fumigación
crearEmpleadoAuth(username, contraseña) -> Cloud Function
    verifica token, correo del dominio y permiso "empleados" leído del servidor
    username /^[a-z0-9._-]{2,40}$/, contraseña >= 6
    crea la cuenta con el correo interno derivado del username, emailVerified = true
    si ya existe: 409 con su uid
guardar firebaseAuthUID en la ficha y escribir empleados_uid_lookup/{uid} = { empleadoId }
mostrar credenciales a quien creó

En el primer inicio de sesión, si la ficha no tiene lookup, el propio empleado puede leer su ficha por firebaseAuthUID y crear su fila del lookup (lo hacen employee-store.js e iniciar_sesion.html).

Edición

La identidad es el username y no cambia: nombre y apellido se corrigen libremente sin tocar referencias. codigoVersion se conserva (guardar la ficha no puede revivir un gafete anulado). Los permisos se fusionan con los existentes. Si cambian estado o departamento, se sincronizan las bodegas (crear, inactivar en cero o avisar que hay saldo que liquidar).

Baja (functions/empleados-baja.js, trigger empleadoBajaOnWrite)

Trigger onDocumentWritten sobre empleados/{id}.

dadoDeBaja(ficha) = estado en {inactivo, suspendido} (sin distinguir mayúsculas)
                    o activo == false o estado == false
                    ("Vacaciones" y vacío cuentan como activo)
al escribir:
    antes = dadoDeBaja(before) o null si no existía
    despues = dadoDeBaja(after) o true si se borró
    si antes == despues: nada (una edición cualquiera no cuesta)
    uid = firebaseAuthUID de after o before; si no hay: anotar en la bitácora y salir
    si despues (baja):
        deshabilitar la cuenta de Auth si no lo está
        revocar refresh tokens
        borrar empleados_uid_lookup/{uid}  (isEmpleado() deja de pasar sin tocar reglas)
    si no (alta):
        rehabilitar la cuenta si está deshabilitada
        recrear el lookup si falta o apunta a otra ficha
    empleados_baja_log += { empleadoId, uid, accion, estado, cuentaExiste, hechos[] }

Es idempotente: compara el estado deseado con el real y solo toca lo que falta.

Resolución de la ficha en el servidor (functions/auth-empleado.js)

conFicha(token):
    caché por uid: 5 minutos si hay ficha, 1 minuto si no hay (o está de baja)
    1. empleados_uid_lookup/{uid} -> empleados/{empleadoId}
    2. respaldo: empleados donde firebaseAuthUID == uid
       (NO por id == username: con el registro abierto alguien podría crear usuario@dominio
        y calzar con la ficha de quien entra)
    si no hay ficha o dadoDeBaja: null (401 en el endpoint)
    devolver { empleadoId, username, permisos: con herencia, esGerencia, departamento }
esGerencia = departamento o cargo contiene "gerenc"/"gerent", o permisos.gerencia == true
exigirPermiso(usuario, claves) = esGerencia o alguna clave en true

Reglas de Firestore de la ficha

Acción Quién
Leer empleados cualquier empleado; o la propia ficha por firebaseAuthUID antes de tener lookup
Actualizar la propia ficha solo fotos de documentos, fotoPerfil y nombrePreferido
Crear, actualizar, borrar otra ficha empleados, nunca sobre la propia (salvo gerencia), y sin dejar una ficha ajena apuntando al propio uid
Quitarse permisos a uno mismo empleados, solo el campo permisos, sin GANAR empleados, planilla ni reportes_gerenciales
Leer privado la propia persona, planilla, empleados, reportes_gerenciales o gerencia
Escribir privado empleados o gerencia, nunca sobre la propia salvo gerencia ("para que nadie se suba el salario")

Permisos

Mecanismo

check-permissions.js (síncrono, en el <head>) define PAGE_PERMISSIONS (página a clave o lista de claves; acceso si tiene cualquiera) y prefijos (quiz- a capacitacion, admin_inventario_ a administrador_bodega, proc_mp a procedimientos_salud). Al cargar espera la sesión (hasta 75 intentos de 200 ms), refresca permisos y, sin permiso, muestra aviso. Con sesión de Firebase viva no expulsa aunque la ficha tarde en llegar por red lenta. Si la ficha dice que la cuenta está de baja, deja de reintentar y muestra "cuenta bloqueada". El menú (header.html) filtra con data-permission.

Herencia (PERMISOS_HEREDADOS, misma tabla en navegador y servidor): mientras la clave nueva no exista en la ficha, vale la vieja. Así se separaron accesos sin dejar a nadie afuera, y marcando la clave nueva se pueden revocar por separado. Ejemplos: logistica y ruteador heredan de calendario; embudo_vendedor de whatsapp; arqueo_inventario, traslado_expres y guachiman de administrador_bodega; la mayoría de módulos de gerencia (reposicion, liquidaciones, mini_tactico, gps360, tabla, politicas, cumplimiento_salud, etc.) de reportes_gerenciales; videos_publicitarios de cualquiera de reportes_gerenciales, embudo_vendedor o whatsapp. anular_monto_cita no se hereda a propósito.

Para agregar un módulo: clave en PAGE_PERMISSIONS, casilla y entrada en MODULOS_ACCESO de empleados.html, enlace en header.html con data-permission, título en load-headersecure.js. Los módulos nuevos de gerencia llevan su propia clave que hereda de reportes_gerenciales.

MODULOS_ACCESO (claves de la ficha)
Clave Botón Clave Botón
calendario Agenda logistica Logística
ruteador Ruteador empleados Equipo (gerencia de personal)
politicas Políticas planilla Planilla
registro_horas Horas edicion_horas Alertas (edición de horas)
reportes_gerenciales Reportes pulso Pulso
operario_bodega Bodeguita administrador_bodega Bodega
despacho Despacho gps GPS Gerencia
gps_ventas GPS Ventas gps360 GPS 360
tabla Tabla de citas arqueo_inventario Arqueo Inv.
publicidad Publicidad respuestas_coordinacion Resp. coord.
satisfaccion_clientes Satisfacción promesas_bloqueadas Promesas
inteligencia_sitio Intel. sitio videos_publicitarios Videos public.
videos_reuniones Reuniones cotizaciones Cotizaciones
cotizaciones_aprobar Aprobar cotiz. bot_ventas Vend. virtuales
cumplimiento_salud Cumplimiento procedimientos_salud Procedimientos
traslado_expres Traslado guachiman Guachimán
reposicion Reposición liquidaciones Liquidación
whatsapp_empresa WA empresa whatsapp_empresa_propio WA ventas
portal_clientes_admin Cartera garantias Garantías
tickets Tickets ruta Ruta
suplantar Ver como actividades Actividades
actividades_admin Activ. admin ventas_calle Ventas
comisiones Táctico mini_tactico Mini táctico
oficinista_calle Calle mapa Mapa
lealtad Lealtad recuperador_cartera Recuperador
atlas Atlas conocimiento Conocimiento
ecobot EcoBot leads Leads
whatsapp Embudo embudo_vendedor Vendedor
herramientas_admin Asignación herramientas Herramientas
capacitacion Capacitación roi_pauta ROI
splitwise Splitwise habilitado Autorizado
arqueo Arqueo Pagos cxc CxC
leads_b2b B2B automatizaciones Flujos
anular_monto_cita Anular monto (no abre pantalla: habilita un botón)

El contador de permisos de cada empleado se calcula sobre esta misma lista con herencia (antes una lista aparte con 22 de 36 claves hacía que dos personas con accesos distintos mostraran el mismo número).

Nombre preferido (nombre-empleado.js)

Donde un empleado se ve a sí mismo o a un compañero se muestra el nombre preferido; el legal completo se reserva para planillas, certificados, contratos y auditoría. Dos campos que no se mezclan: nombrePreferido (manda en toda la app) y aliasRuta (apodo corto de gafete, solo para el cronograma de rutas).

nombreDisplay(emp) = nombrePreferido || primerNombre || nombre completo || username
nombreCorto(emp)   = primera palabra del preferido (o primer nombre) + inicial del primer apellido

Se usa como módulo o como window.NombreEmpleado.display(emp).


Perfil (perfil.html)

La propia persona: foto de perfil (recortador cuadrado estilo mensajería, 512 x 512 JPEG, arrastrar y pellizcar para zoom), nombre preferido, fotos de cédula y licencia. Solo puede escribir esos campos de su ficha (regla de Firestore). Directorio del equipo con foto y primer nombre o preferido, sin datos personales. La foto nueva se refleja en el ícono del header y en la sesión.

Gafetes (gafetes.html, js/codigo-gafete.js)

Gafetes de 90 x 60 mm, ocho por hoja carta (2 x 4), con un código de barras de 82 x 21 mm. Clave empleados. Nadie marcado al entrar (imprimir toda la planilla por accidente eran veinte gafetes vigentes que nadie pidió).

codigoGafete(username, version):
    semilla = "{dominio}|{username}|{version}"
    repetir SHA-256(semilla + contador) y tomar de cada byte < 228 un carácter de un alfabeto de 57
        símbolos sin l, 0, 1, I, O (que no se confunden a mano), hasta 20 caracteres
validar(leido, empleado) = leido == codigoGafete(empleado.username, empleado.codigoVersion)
anular un gafete impreso = subir empleados.codigoVersion (v1 -> v2): el código viejo deja de coincidir

No hay tabla de códigos: quien imprime y quien valida lo recalculan igual.


Registro de horas: Pulso (registro_horas.html, js/registro-horas.js)

Propósito

Marca de entrada y salida de cada colaborador, con GPS, que alimenta la planilla.

Documento work_sessions

Campo Significado
username, userId, firebaseAuthUID Quién
startTime, endTime Marcas (endTime null mientras está abierto)
status working o completed
gpsStart, gpsEnd, distanciaInicio, distanciaFin Ubicación y distancia a la sede central
inicioFueraDeCentral, finFueraDeCentral Excepciones
bodegaAPV, bodegaAPVNombre Vehículo (fuera de uso)
anulado Turno anulado: no se paga

Reglas

  • Desde el 19-oct-2026 el turno solo se inicia y se cierra en la sede central: radio de 300 m, con 150 m de tolerancia por imprecisión del GPS bajo techo (distancia por fórmula de Haversine). Fuera del radio o sin ubicación no se inicia; las excepciones las abre un supervisor desde despacho o pulso. Cada excepción genera una alerta a gerencia (alertas_gerencia, tipo turno_fuera_radio, con la distancia).
  • Candado contra doble toque: dos toques creaban dos sesiones del mismo día y la planilla las veía como dos jornadas.
  • La sesión en memoria se arma solo si Firestore confirmó el guardado (antes el colaborador quedaba con "sesión activa" sin turno real).
  • Al cerrar se relee el documento: una pestaña vieja podía volver a cerrar un turno ya cerrado y pisarle la hora de salida.
  • Limpieza de duplicados: si hay varias sesiones abiertas, se cierran todas menos la más reciente.
  • Al iniciar en ruta, revisión del inventario de especiales con creación de ticket por faltantes. Al cerrar, recordatorios no bloqueantes (citas sin método de pago, citas sin marca de ingreso o salida, especiales sin liquidar del día operacional, que se recalculan al cerrar porque el ruteador pudo cambiar durante el día).
  • El colaborador ve su historial por mes en tarjetas semanales (lunes a domingo), con horas, extras y pendientes calculadas con la MISMA regla que la planilla.

Tipo de jornada

por hora de inicio del turno más temprano del día:
    5:00 a 11:59  -> Diurna   (8 h esperadas)
    12:00 a 18:59 -> Mixta    (7 h esperadas)
    19:00 a 4:59  -> Nocturna (6 h esperadas)

Solicitud de vacaciones

Desde la misma pantalla.

diasEfectivos = días elegidos (sin los no laborables)
validar:
    no traslapar con solicitudes pendientes o aprobadas propias
    si la ficha tiene fechaIngreso: saldo = ganados - redimidos (ver fórmulas en Vacaciones)
    sin fecha de ingreso: no se bloquea, queda en manos de RR. HH.
vacaciones += { empleadoId (username), empleadoNombre, estado: "pendiente", fechaInicio, fechaFin,
                fechaSolicitud, dias[] (timestamps), totalDias, motivo }

Edición de horas y alertas (edicion_horas.html)

Clave edicion_horas (botón "Alertas").

Sección Contenido
Requieren revisión Solo Fumigación, sesiones de más de 12 h; indica las de más de 500 m de la sede y abre mapa
Revisados Los ya marcados
Lista Todos los registros del rango (por defecto el último mes), filtros por empleado
Reporte de más de 500 m Inicios y cierres fuera de 500 m por empleado en 30 y 90 días, con mapa
Historial Cambios por registro desde work_sessions_logs
editar(registro):
    nadie edita ni borra SUS PROPIAS horas, salvo un usuario de gerencia específico
    justificación obligatoria (antes el required no se evaluaba porque el botón no era submit)
    fechas en hora local (toISOString corría un día los turnos que empiezan después de las 18:00)
    si se fija endTime: status = "completed"
        (si quedaba "working", planilla no lo pagaba y el empleado seguía bloqueado con sesión activa)
    updateDoc(work_sessions)
    DESPUÉS: work_sessions_logs += { cambios detectados campo por campo, justificación, quién, cuándo }
        (antes el log se escribía aunque el guardado fallara)

Planilla (planilla.html, js/planilla.js, js/planilla-motor.js, js/planilla-core.js)

Propósito

Calcular el bruto, el neto y lo pendiente de pagar de cada persona por MES (con dos pagos de quincena), con horas extra desde Pulso, comisiones desde las citas, bonos y deducciones. Clave planilla.

Arquitectura

  • planilla-core.js: funciones puras de horas y comisiones (las usan Pulso, planilla y liquidación; "si las dos pantallas divergen, una promete plata que la otra no paga").
  • planilla-motor.js: cálculo del bruto del mes por persona y cargas desde Firestore.
  • planilla.js: tabla, edición, ocultos, fijos, alias, corte histórico.

Lecturas

empleados (frescos, sin la caché de 15 minutos, más privado/datos), work_sessions del mes, citas por fecha_mes (mes operacional, con el corte de las 5 a. m.), ruteador del mes (técnicos por día y ruta), vacaciones aprobadas, planilla (lo capturado a mano), planilla_alias, encuestas (métricas informativas).

Lo capturado a mano: planilla/{AAAA-MM}__{username}

pago_i_quincena, pago_ii_quincena, adelantos, pension_alimentaria, bono_adicional, dia_extra, salario_bruto (corrección), valor_hora_extra.

Ajustes de la tabla: planilla_alias/{username}

Alias visual del nombre, categoría visual (con catálogo en un documento reservado), oculto (sacarlo de este reporte NO es darlo de baja) y fijo (agregado a mano: sale todos los meses aunque no mueva nada, para gerencia que no marca turno). Ninguno toca la ficha, la boleta ni el histórico.

Horas (regla de Pulso)

solo el departamento Fumigación cobra horas extra (para los demás, extras = 0)
departamentos con almuerzo (Ventas, Administración, Logística, RR. HH., Contabilidad, Gerencia):
    se rebaja 1 h por día trabajado (solo en días con marcas reales)
sesiones anuladas o sin cierre no cuentan

mensual:
    por semana (lunes a domingo): esperado = suma de horas de jornada de cada día trabajado
    dif = laboradas - esperado; si > 0 extras de la semana, si < 0 horas pendientes
    extras del mes = suma de extras semanales positivas; pendientes = suma de pendientes
por horas:
    por día: extra = max(0, laboradas - esperado del día)

Pago de los empleados por horas

por persona y día:
    jornadas = horas por tipo (Diurna, Mixta, Nocturna) según la hora de cada sesión
    día de vacaciones aprobada sin marcas: 8 h Diurnas (si hubo marcas, se paga lo trabajado)
    almuerzo: si corresponde, se rebaja 1 h de la jornada con más horas
    tarifas = tarifas_jornada de la ficha para la jornada base del día, si no las de defecto
              (la extra de defecto es 1,5 veces la ordinaria)
    si jornada simplificada (por horas sin extras):
        ordinario = horas totales * tarifa ordinaria Diurna; extra = 0
    si no:
        ordinario = horas esperadas del día * tarifa ordinaria
        extraHoras = total del día - esperadas
        extra = extraHoras * tarifa extra si >= 0, si no extraHoras * tarifa ordinaria (resta)
salarioBruto = suma de ordinarios del mes; ingresoExtra = suma de extras
valorHora mostrado = extra / horasExtra si |horasExtra| >= 0,25, si no la tarifa extra Diurna

Comisiones de ventas

desde febrero 2026 (antes no comisiona)
para cada cita del período con monto > 0 que no está en la ruta de canceladas (Ruta 20):
    vendedores = metadata.vendedores
    porcentajes = metadata.excepcion_pesos_vendedores si suma 100 y coincide en largo,
                  si no reparto parejo (100 / n, el residuo a los primeros)
    tipo = "oficina" si algún vendedor tiene vendedor_leads_pauta, si no "calle"
    ventas[vendedor][tipo] += monto * pct
comision(vendedor) =
    max(0, ventas.oficina / 1,13 - meta_ventas_base) * comision_ventas_oficina_pct
  + (ventas.calle / 1,13) * comision_ventas_calle_pct

La base "oficina" (leads de pauta) paga solo por encima de una meta; la de "calle" paga desde el primer colón.

Bono topo y ventas traídas

bono topo: para citas con metadata.topo, monto > 0, del mes operacional, fuera de Ruta 20:
    para cada técnico del ruteador de ese día y ruta (sin repetir):
        bono += monto/1,13 * 1 %   (un técnico específico está hardcodeado con 3 %)
ventas traídas (indicador): monto/1,13 repartido en partes iguales entre los técnicos de la ruta
facturación por hora = ventas traídas / horas laboradas

Bruto, neto y costo

salarioBruto = por horas: ordinario calculado
               mensual: planilla.salario_bruto si se capturó, si no el salario de la ficha
               gerencia: base fija de gerencia si la ficha no trae salario
ingresoExtra = por horas: el calculado; mensual: horasExtra * precioHora
    precioHora = planilla.valor_hora_extra, si no precio_hora_sencilla_por_mes[mes],
                 si no el del último mes anterior que tenga (heredado), si no un valor de defecto
totalBruto = salarioBruto + ingresoExtra + comisiones + bonoTopo + bonoAdicional + diaExtra

CCSS (aporte del trabajador) = 10,83 %
aplicaCcss = jornada simplificada, o departamento distinto de "Gerencia" y de "Sin departamento"
excedenteSP = si jornada simplificada y totalBruto > tope_servicios_profesionales:
                  totalBruto - tope (se paga como servicios profesionales sin rebajo)
salarioNeto = aplicaCcss ? (totalBruto - excedenteSP) * (1 - 0,1083) + excedenteSP : totalBruto

pagos de quincena: los capturados; por defecto un monto fijo solo para mensuales (por horas: 0, y la fila
    se marca como "no capturado": antes se inventaban pagos y salían pendientes negativos)
totalDevengos = pagoIQuincena + pagoIIQuincena + adelantos + pensionAlimentaria
netoPagar = salarioNeto - totalDevengos

costo patronal (indicador) = totalBruto * (26,5 % + 8,33 % + 4,17 % + 8,33 % + 3 %) = totalBruto * 50,33 %
costoTotal = totalBruto + costo patronal
% costo sobre ventas (Fumigación y Ventas) = costoTotal / (ventas base de comisiones + ventas traídas)

Quién sale en la tabla

Por defecto los que tuvieron movimiento en el mes (horas, salario de ficha, comisiones, bonos o día extra distintos de cero). Un mensual con salario en ficha sale siempre (antes gerencia, que no marca turno, quedaba invisible y no se le podía capturar nada). Los ocultos salen antes de sumar totales (si no, el resumen contaba gente que no se veía). Los fijos entran siempre. Orden: Fumigación, Ventas, resto.

Corte histórico

Solo desde la vista "solo con movimiento" (para no archivar filas en cero).

historico_planillas += HTML congelado de la tabla (inputs convertidos en texto, sin botones)
planilla_cierres/{mes}__{username} (lotes de 400) = {
    mes, username, nombre, departamento, tipoPago, salarioBruto, horasLaboradas, horasExtra,
    ingresoBrutoHoraExtra, ingresoBrutoComisiones, ventasBaseComisiones, bonoTopo, bonoAdicional,
    diaExtra, totalBruto, salarioNeto, netoPagar, guardadoEn, guardadoPor }
volver a guardar el mismo mes pisa: manda el último cierre

historico_planillas.html (clave planilla) lista y muestra los cortes guardados. Reglas: planilla y historico_planillas solo con planilla o reportes_gerenciales; planilla_cierres también con liquidaciones.


Liquidación de colaboradores (liquidacion.html, js/liquidacion.js)

Propósito

Calcular lo que se le paga a una persona al salir, replicando la hoja de cálculo de liquidaciones de gerencia, pero llenando cada dato solo desde el sistema y permitiendo corregirlo a mano. Clave liquidaciones (hereda de reportes_gerenciales). Se guarda en liquidaciones por colaborador y último día.

Fuentes

Dato Fuente
Fecha de ingreso, cédula, cargo, % de comisión Ficha y privado/datos
Salarios de los últimos 6 meses completos antes del mes de salida Motor de planilla mes por mes; si falla, planilla_cierres; si no, lo capturado en planilla; si no, el salario de la ficha (se marca de dónde vino)
Horas extra del mes de salida Pulso hasta el último día, con la regla de la planilla
Ventas comisionables del mes de salida Citas hasta el último día, con la regla de la planilla (sin IVA)
Vacaciones Ganados a la fecha de salida menos disfrutados

Cada campo guarda su valor automático y si se escribió a mano; volver a escribir exactamente el automático lo deja como automático.

Fórmulas

motivos: renuncia, despido con responsabilidad patronal, despido sin responsabilidad,
         fin de período de prueba, mutuo acuerdo
pagaIndemnizacion = motivo en {despido con responsabilidad, mutuo acuerdo}

mesesAntiguedad = (último día - ingreso, contando ambos extremos) / 30

preaviso (art. 28), si pagaIndemnizacion:
    < 3 meses: 0;  3 a 6: 7 días;  6 a 12: 15 días;  12 o más: 30 días
cesantía (art. 29), si pagaIndemnizacion:
    < 3 meses: 0;  3 a 6: 7 días;  6 a 12: 14 días
    12 o más: años = min(8, años completos + 1 si la fracción es >= 6 meses)
              días por año según años: 1:19,5  2:20  3:20,5  4:21  5:21,24  6:21,5  7:22  8:22
              cesantía = díasPorAño * años

vacaciones ganadas = meses decimales trabajados * 1,1667 (14 días por cada 12 meses)
vacaciones disfrutadas = días de solicitudes aprobadas desde el 1-dic-2025 (por días del período, no por
                         fecha de aprobación) + diasVacacionesAntes2025 de la ficha
saldo vacaciones = floor(ganadas - disfrutadas)

días pendientes de planilla = día de salida - (1 si salió en la primera quincena, si no 16) + 1

promedio mensual = promedio de los meses marcados (un mes en cero no entra: lo hundiría)
diario = promedio / 30
comisiones = ventas comisionables * pct / 100   (pct de la ficha, si no un % de defecto de la hoja)
extras = horasExtra * (diario / 8) * 1,5
díasPendientes$ = diario * díasPendientes
aguinaldo = (suma de brutos de los meses desde el 1 de diciembre anterior a la salida
             [+ díasPendientes$ + extras + comisiones si se marca "incluye mes actual"]) / 12
vacaciones$ = díasVacaciones * diario
preaviso$ = díasPreaviso * diario
cesantía$ = díasCesantía * diario
total = comisiones + extras + díasPendientes$ + aguinaldo + vacaciones$ + preaviso$ + cesantía$ + bono - rebajos

El documento imprimible lleva logo, desglose y el representante legal de la empresa.


Vacaciones

Pieza Dónde
Solicitud Pulso / registro de horas (ver arriba)
Aprobación pulso.html: lista pendientes y aprobadas; cambia estado a aprobada o de vuelta a pendiente
Aviso js/agenda-fab-vacaciones.js: botón flotante en la agenda, solo para gerencia, con las solicitudes pendiente (quién, desde, cuántos días)
Saldo empleados.html: ganados (meses decimales x 1,1667), redimidos (calcularDiasRedimidos: días del período desde el corte de dic-2025 + diasVacacionesAntes2025), saldo; fecha de ingreso y días manuales editables en línea
Pago Planilla: un día aprobado sin marcas cuenta 8 h Diurnas para los que cobran por horas; una vacación que cruza de mes solo aporta los días del mes calculado

Tickets internos (tickets.html, ticketssecure.js, tickets-badge.js)

Propósito

Tareas asignadas entre personas con fecha de vencimiento, fotos y resolución. Clave tickets.

Documento tickets

titulo, descripcion, creadoPor, creadoPorNombre, asignadoA, asignadoANombre, estado (pendiente o completada), prioridad, fechaCreacion, fechaVencimiento (obligatoria, se fija a las 23:59 local del día elegido), fechaResolucion, comentarios[], fotos[] (URLs en Storage, comprimidas antes de subir), fotos de resolución.

Reglas

  • Vista: vencidos, mis pendientes, los que creé para otros (seguimiento), completados y los de otros (plegables).
  • Asignación automática por palabras clave mientras se escribe: términos de inventario ("inventario", "bodega", "stock", "producto faltante"...) sugieren a la responsable de bodega; términos de equipo o nombres de equipos y herramientas del catálogo sugieren al responsable de herramientas. Los dos responsables están escritos en el código por nombre (deuda técnica).
  • Gerencia puede crear un ticket "a nombre de" otro gerente.
  • Tickets en masa (uno por cada empleado activo seleccionado) y reporte de eficiencia: solo con administrador_bodega y tickets a la vez.
  • Al completar se pone fechaResolucion; al volver a pendiente se quita.
  • Candado contra doble toque al crear (sin fotos, dos toques creaban dos tickets).
  • Badge del header y del pie móvil: cuenta MIS pendientes con una agregación del servidor (1 lectura), refresco cada 5 minutos.
  • Otros módulos crean tickets en la misma colección, por ejemplo los faltantes de especiales que el técnico declara al iniciar turno (vencimiento a las 23:59 del día siguiente).

Actividades (actividades.html, js/actividades.js, js/actividades-premios.js, js/actividades-aviso.js)

Propósito

Gerencia arma una actividad (título, cuándo, tema visual, lista de opciones con foto y cantidad fija) y cada compañero dice si va y elige UNA opción. El reporte suma qué comprar, quién pidió qué y cuánto sale.

Datos

actividades/{id} (titulo, cuando, estado, items[] con nombre, cantidad, foto y precio, obligarDesde, cierreDesde, creadoEn, si lleva alcohol), actividades/{id}/pedidos/{username} (uno por persona), actividades_catalogo (opciones usadas antes, para reusar).

Reglas

  • Claves: actividades para participar, actividades_admin para cargar y ver el reporte con precios. El precio no se pinta sin permiso, pero viaja en el mismo documento (limitación declarada).
  • Tres estados de respuesta: no contestó, va, no va.
  • Una sola oportunidad: hecho el pedido el formulario se cierra; para dar otra oportunidad gerencia borra el pedido desde el reporte.
  • obligarDesde: desde esa hora el aviso en las demás pantallas no se puede cerrar sin contestar. cierreDesde: a esa hora nadie más pide (se valida también al guardar, por si una pestaña vieja tenía el botón). Cerrar antes de obligar se rechaza.
  • El reporte sabe a quién se le preguntó (activos con actividades) y lista a los que faltan.
  • La actividad vigente es la última abierta; si no hay, la última creada.
  • Premio mensual "al más rutero": podio de tres con foto de perfil, nota y premio, configurado en el código cada mes; el póster para el grupo de mensajería se dibuja aparte en un canvas de tamaño fijo, sin depender de CDN.

Cuentas por cobrar (cxc.html, cxcsecure.js)

Pese a estar en el área administrativa, no es de empleados: es la cartera por cobrar a clientes con factura (registros, clientes, ejecutivos, estatus, pagos, estados de cuenta, carta de cobro). Clave cxc.

Pieza Detalle
Datos cxc_reg (una línea por documento) y cxc_config/main (estatus, segmentos, ejecutivos, columnas visibles por usuario vis_{usuario}). Cada escritura lleva _updated (milisegundos) y _by.
Concurrencia Copia "sombra" del último estado del servidor por registro: se escriben con merge solo los campos que esta persona cambió (antes se mandaba el registro completo y se perdían pagos o comentarios de otro). No se repinta mientras alguien escribe.
Gerencia Quien tiene empleados: vaciar la base, editar columnas contables, cerrar cuentas como Contabilizado, Incobrables o Pagó, y borrar (también lo exigen las reglas de Firestore). Antes era una contraseña escrita en el JavaScript.
Pagos El método se asigna línea por línea consumiendo el monto declarado de cada uno (antes una etiqueta "A + B" pasaba todas las líneas a datáfono).
Importación Documentos emitidos del facturador y plantilla de contabilidad, con deduplicación por fecha + descripción + monto.
Salidas Excel, PDF e imagen de resumen de pago (librerías cargadas solo al exportar).

Splitwise (splitwise.html)

Gastos compartidos entre compañeros, al estilo de la aplicación del mismo nombre. Clave splitwise. No se integra con la planilla.

Colección Contenido
splitwise_grupos nombre, miembros[] (usernames), activo
splitwise_gastos grupoId, monto, pagadoPor, divididoEntre[], descripción, fecha, imágenes, o un pago entre dos miembros con comprobante
balance[u] = suma de lo que u pagó - suma de su parte (monto / tamaño de divididoEntre) en cada gasto
netear (minimizar transacciones):
    deudores ordenados por deuda, acreedores por crédito; emparejar el mayor con el mayor,
    transferir el mínimo de los dos, avanzar; ignorar diferencias menores a 1 colón
sin netear: deuda directa por par, neteando solo A-B contra B-A

Al guardar un gasto primero se crea el documento para tener id; si la subida de imágenes falla, el reintento reusa ese id y no duplica.


Mini táctico (mini-tactico.html, functions/mini-tactico.js)

Reporte de ventas personal del vendedor que entra (lo que vendió en el mes, recientes y su avance), no de personal. Toca a personal solo en que está atado a la identidad: el endpoint miniTacticoDatos (HTTP, 1 GiB, 120 s) exige mini_tactico o comisiones, arma una foto por mes con solo los campos que usa el reporte (máscara de campos: sin reportes, fotos ni GPS), la guarda comprimida en tactico_ventas_cache/{mes} (vigencia 5 minutos para el mes en curso y el anterior, 6 horas para meses cerrados, tope de 900 KB comprimido) y a cada vendedor le devuelve únicamente las citas donde aparece como vendedor. Antes la página bajaba entre 200 y 970 citas completas por cada actualización.


Reporte de empleados por horas (reporte_empleados_por_horas.html)

Clave reportes_gerenciales. Resume horas por empleado a partir de work_sessions y vacaciones. No se revisó en detalle.

05

Gerencia, medición, flota y cumplimiento

Este capítulo describe los módulos con los que gerencia mide el negocio (reportes, tableros, pauta y atribución), controla la flota (GPS, velocidad, llegadas) y sostiene el cumplimiento sanitario y la capacitación, más el contenido publicitario y el manejo de problemas reportados. Todo corre sobre la misma base del resto del sistema: páginas HTML sin compilación, Firestore como base de datos, Cloud Functions en Node para lo programado y los endpoints, y Storage para archivos.

Convenciones comunes de este capítulo

Acceso

  • Cada página se mapea a una clave de permiso en un diccionario PAGE_PERMISSIONS (nombre de archivo a clave o lista de claves; con lista alcanza cualquiera).
  • Hay mapeo por prefijo para familias grandes: quiz- a capacitacion, proc_mp a procedimientos_salud, admin_inventario_ a administración de bodega. Una página que no está en el mapa abre con cualquier sesión, por eso el mapeo por prefijo existe.
  • Los módulos nuevos tienen clave propia que, mientras no exista en la ficha del empleado, hereda de reportes_gerenciales.
  • Si la sesión está viva pero la ficha del empleado no cargó, la página no expulsa: reintenta.

Venta, día y moneda

Concepto Definición
Venta cita con metadata.monto > 0 que no está en "Ruta 20" (Ruta 20 es la bolsa de canceladas). El criterio estricto (táctico, fotos de ventas) exige rutas 1 a 19, así que "Sin asignar" tampoco cuenta
Día operacional de 5:00 a 4:59 del día siguiente, UTC-6 fijo (Costa Rica no tiene horario de verano). inicio_gmt6 guarda la hora local interpretada como UTC
Moneda colones enteros, sin céntimos. Google Ads reporta en USD (micros) y se convierte con un tipo de cambio fijo de 450
Teléfono id de la conversación: 506 + 8 dígitos. Se normaliza quitando no dígitos y anteponiendo 506 a los de 8 dígitos

Reparto de una cita entre vendedores

porcentajesPorVendedor(cita):
  n = cantidad de vendedores en metadata.vendedores
  si n == 0: todo a "Sin vendedor"
  si metadata.excepcion_pesos_vendedores tiene n elementos y suman 100: usar esos pesos
  si no: base = floor(100 / n); resto = 100 - base * n
         los primeros "resto" vendedores reciben base + 1, el resto base

Algunos reportes (detalle de ventas, socios) cuentan la cita completa para cada participante y lo dicen en pantalla; los que hablan de comisión usan los pesos.

Cachés de reportes pesados

Los reportes que leen cientos de miles de documentos no calculan al abrir: leen una caché (reportes_<nombre>/{periodo} con un número de VERSION; si la versión no coincide se descarta) y solo recalculan con un botón. Los totales del equipo se recalculan desde datos crudos o histogramas, nunca promediando promedios.

Centro de reportes (reportes.html)

Propósito

Índice único de los reportes gerenciales, agrupado por área, con tres indicadores calculados en la página. Permiso reportes_gerenciales. Cada tarjeta puede pedir un permiso extra (data-perm): si el usuario no lo tiene la tarjeta se oculta, y si una sección queda vacía se oculta entera ("una tarjeta que lleva a sin acceso es peor que no mostrarla").

Indicadores en la página

  • Vendido este mes y citas este mes: citas con inicio_gmt6 dentro del mes calendario del navegador, sin Ruta 20, sumando metadata.monto numérico.
  • Activos hoy: usuarios distintos en el diagnóstico de lecturas desde la medianoche.
  • Caso borde: el indicador usa mes calendario e incluye "Sin asignar", así que no cuadra exacto con el táctico (que usa día operacional y rutas 1 a 19). Si falla, queda en "…" sin avisar.

Tarjetas por área

Área Reportes
Pauta y ventas ROI de pauta, campañas, citas sin ruta, reporte de canales (número oficial de pauta), revisión de ubicaciones, leads por día y hora, métricas de vendedores, eficiencia de vendedores, detalle de ventas, confirmaciones del día siguiente, estrategia macro, auditoría de ventas (compara dos fotos), blindaje de citas, estudio de mercado, conversión por vendedor, tablero de decisión, inteligencia del sitio
WhatsApp y leads analítica de WhatsApp, rendimiento de plantillas, bot de ventas, leads B2B, desempeño del CRM, leads por hora, promesas bloqueadas
Desempeño de la gente evaluación de técnicos (0 a 100), puntualidad, cumplimiento de ingresos, evaluación de vendedores por los técnicos, justificaciones del optimizador, escritura de vendedores, encuestas, curados en salud, horas por empleado, satisfacción de clientes
Campo y flota dónde está el equipo, velocidad, flota, mapas de ubicaciones, zonas, correcciones de GPS, actividad, mapa de recorridos
Dinero e inventario arqueo, cuentas por cobrar, tablero e informes de inventario, gasto de inventario, auditorías de transacciones y stock, lealtad
Diagnóstico y datos back end, diagnóstico de lecturas, editor de clientes, solicitudes de reportes, almacenamiento

reportes_gerenciales.html es solo una redirección a pulso.html que conserva parámetros y ancla.

Solicitudes de reportes (solicitudes_reportes.html)

Cualquier reporte puede tener un cuadro "pedir un reporte" (texto o dictado por voz en español de Costa Rica) que escribe solicitudes_reportes/{id} = {texto, usuario, fecha, completado: false, pagina}. Gerencia los lista por fecha y los marca completados.

La empresa en números (ecoplagas_en_numeros.html)

Ficha estática de presentación de los últimos 12 meses: servicios realizados, clientes únicos, ticket promedio y mediano, porcentaje de recurrentes, barras por mes, frecuencia contra valor, concentración en el 10 % superior, porcentaje de nuevos, mediana de días entre visitas y geografía por provincia. No lee la base: los números están en el HTML. Metodología escrita en la página para recalcular:

Métrica Regla
Fuente citas del periodo sin canceladas
Ticket promedio y mediano solo servicios con monto > 0
Revisiones y garantías sin costo cuentan como atendidos, no entran al ticket
Cliente único por teléfono
Recurrente 2 o más servicios en el periodo
Nuevo sin visitas antes del periodo
Geografía solo visitas con provincia registrada

Riesgo: se actualiza a mano.

Detalle de ventas (reporte_ventas_detalle.html)

Propósito

Qué servicios vende cada vendedor y a qué precio. Permiso reportes_gerenciales.

Flujo

  1. Abre con "este mes"; atajos de mes anterior y últimos 3 meses.
  2. Lee empleados, catálogo servicios y citas por inicio_gmt6 en el rango; descarta Ruta 20 y monto <= 0.
  3. Cada username de metadata.vendedores se resuelve: vendedor de departamento Ventas seleccionado, grupo virtual "Gerencia" (departamento gerencia o administración), o "Técnicos / Otros". Los grupos se deduplican por cita.

Métricas

participación del vendedor = suma del monto COMPLETO de cada cita en que participó
ticket global              = round(total / citas)
ticket por vendedor        = round(totalVendedor / citasVendedor)
precio promedio por servicio = Σ precio / Σ conteo   (de metadata.servicios[].precio)
color de una cita: alto si monto >= ticketGlobal, bajo si monto < 0,7 * ticketGlobal
matriz servicio x vendedor:
   promedio de precios > 0, conteo, % de precio cero = ceros / total
   escala térmica t = (prom - minProm) / (maxProm - minProm)
   orden de filas por Σ prom * conteo

Exporta CSV con ; y BOM.

Métricas de vendedores (reporte_metricas_ventas.html, metricas-ventas.js)

Propósito

Una tabla comparativa para la reunión de ventas que cruza turno pagado, actividad real, reacción al lead, leads abandonados y plata. Permiso reportes_gerenciales. Periodos 7, 30 (defecto) y 90 días.

Datos

Cálculo en el navegador, sin función de servidor, con lecturas paginadas de 3.000:

Colección Uso
whatsapp_activity (toda la historia) asignaciones y pases de leads
citas por fecha_creacion ventas
whatsapp_messages por timestamp conversación
work_sessions por startTime horas de turno
presence_days/{usuario}_{fecha} minutos activos (lotes de 120)
reportes_escritura (último) nota de ortografía generada por IA

Caché reportes_metricas_ventas/{dias}d (VERSION = 4). Treinta días son del orden de 150.000 lecturas.

Constantes

Ventana de atención de 24 h, abandono a las 24 h, almuerzo de 1 h por día trabajado, tope de 14 h por sesión que quedó abierta.

Lógica no obvia

1. Primera asignación de cada teléfono en toda la historia (eventos asignado/autoasignado).
   El destino es el último nombre entre comillas del detalle (o lo que va después de la flecha).
   Si el origen era otro vendedor, es un PASE, no un lead nuevo.
   Lead del periodo = teléfono cuya primera asignación cae en la ventana.

2. Línea de tiempo de dueños por teléfono [{t, dueño}]; "Sin asignar" = dueño nulo.
   duenoEn(teléfono, t) por búsqueda binaria.

3. Venta = cita creada después de la asignación con monto != 0.
   Se acredita a quien tenía el lead AL VENDER (lectura "rescate");
   en paralelo al primer dueño (lectura "leads propios", campos terminados en P).

4. Por conversación de un lead (solo mensajes posteriores a la asignación y no fallidos):
   abierto = nulo
   entrante: recibidos++; si abierto es nulo: abierto = t
   saliente: enviados++
             si primera reacción no medida y lo envió el dueño: reaccion = t - asignación
             si abierto: espera = t - abierto; si espera > 24 h: ventanasPerdidas++
                         si es la primera respuesta: contar <= 5 min y <= 60 min
                         abierto = nulo
   al final: si quedó abierto: (ahora - abierto) > 24 h ? abandonado : sinRespuestaAún
             si nunca hubo saliente y pasaron > 24 h desde la asignación: abandonado
   (un lead se cuenta una sola vez; antes el abandono llegó a superar el 100 %)

5. Raspado = plantilla saliente sin entrante previo o con > 24 h desde el último entrante.
6. Ajenos: saliente de alguien que no es el dueño (el autor suma "ajeno", el dueño "invadido");
   si el lead no tenía dueño, el autor suma "rescatado". Usuarios bot se ignoran.

7. Horas de turno: sesiones completadas o abiertas por usuario y día,
   min(14, fin - inicio), menos 1 h de almuerzo por día.
   Horas activas: minutos con clic, tecla o scroll / 60 (minutos solo "activos" sin acción no cuentan).

8. Tiempos como MEDIANA desde histogramas (casillas de 1 min hasta 120 min, 30 min hasta 24 h,
   6 h hasta 14 días, y una final); los cortes < 5, <= 15 y <= 60 min se cuentan exactos aparte.

Fórmulas

leadsTrabajados   = leads + recibidosDeOtros
conversion        = compraron / leadsTrabajados        porLead  = monto / leadsTrabajados
conversionP       = compraronP / leads                 porLeadP = montoP / leads
ticket            = monto / compraron
aprovechamiento   = horasActivas / horasTurno
montoPorHora      = monto / horasTurno
tasaAbandono      = abandonados / leads
tasaRaspado       = raspadosNuevos / leads
pctBajo5          = primeras respuestas <= 5 min / primeras respuestas
horaArranque      = mediana de la hora del primer saliente del día (en cuartos de hora)
cicloHoras        = mediana desde que ESE vendedor tomó el lead hasta la primera venta

Índice de 0 a 100: normalización mínimo-máximo dentro del grupo visible, invirtiendo las métricas donde menos es mejor; un dato faltante vale 0,5.

Métrica Peso
porLead 24
conversion 18
reacción a la asignación (menos es mejor) 14
aprovechamiento 12
tasa de abandono (menos es mejor) 12
% bajo 5 min 8
tasa de raspado (menos es mejor) 8
ortografía 4

Hallazgos automáticos (con 5 leads o más): quién lleva el periodo, quién rescató plata ajena, reacción lenta (> 60 min) o rápida (<= 15), aprovechamiento < 0,5 o >= 0,6 (con 8 h o más de turno), abandono > 0,15, raspados > 0, 5 o más ajenos o rescatados, ortografía < 80, mejor monto por hora, citas sin vendedor. Se puede sacar a un vendedor de la tabla y todo se recalcula sin releer (la lista queda en el navegador). Si el registro de asignaciones empieza después del periodo pedido, avisa "cobertura parcial".

Desempeño del CRM (desempeno_crm.html, desempeno-crm.js)

El "cómo" del CRM: velocidad de respuesta, abandono, insistencia, recurrencia y ciclo de venta. Permiso reportes_gerenciales. Mismas lecturas que métricas, caché reportes_desempeno_crm/{dias}d, periodos 7 (defecto), 30 y 90 (90 días son del orden de 250.000 documentos).

Diferencias con métricas de vendedores: la venta se acredita siempre al primer dueño (por eso las dos páginas ya no dan la misma cifra), guarda arreglos completos y calcula mediana, promedio y P90 (s[floor(0,9 * n)]).

ratioEnvioRecibo = enviados / recibidos      (< 1: el cliente escribe más de lo que le responden)
tasaRecurrencia  = clientes con más de 1 cita / compraron
citasPorCliente  = citas / compraron

Semáforos: primera respuesta roja > 60 min; < 5 min verde con >= 50 %; respuesta sostenida roja > 120 min; abandono rojo > 15 %; contactados rojo < 80 %. En la ficha de cada vendedor el delta se compara con la mediana del equipo y se oculta si es menor al 5 %.

Leads por hora (leads_por_hora.html)

Mapa de calor de leads nuevos: días en filas (el más reciente arriba) y 24 horas de Costa Rica en columnas. Permiso embudo_vendedor, whatsapp o reportes_gerenciales.

  • Lee whatsapp_conversations con sourceAt (primer toque, congelado) desde el inicio del periodo (7, 14 defecto, 30, 60 días).
  • Solo cuentan los canales que son lead (Meta, Google Search, YouTube, Bing, TikTok, sitio web, directo, Messenger); fuera salientes, empleo y contactos agregados a mano; los de Meta cuya campaña o texto parecen de empleo se descartan.
  • Hora local sin depender del navegador: fecha = new Date(ms - 6 h) leída con métodos UTC.
  • Color: nivel = ceil(valor / máximo * 5) con UNA escala para toda la tabla, para poder comparar días. Las horas futuras de hoy quedan vacías. El promedio por día usa solo días completos.

Actividad (reporte_actividad.html)

Qué hizo cada empleado en el sistema minuto a minuto. Permiso reportes_gerenciales.

Pestaña Contenido
Actividad real 1.440 minutos por persona desde presence_days: verde si hubo clic, tecla o scroll; amarillo activo sin acción; rojo latido sin entrada; negro sin latido. Se superpone el turno de work_sessions. En vivo desde presence_live (sin latido en 150 s = desconectado), leído una vez y refrescado cada 30 s solo con la pestaña visible y con foco
Mensajes por día salientes de los últimos 14 días por persona, deduplicados por id de WhatsApp
Comparar vendedores por franja de 5 a 60 min: enviados, latencia P75 (de un entrante al siguiente saliente de ese vendedor) y cargas de página
Consultas a la base mapa de calor de 96 franjas de 15 min por usuario desde el diagnóstico de lecturas, con puntos de ubicación por celda

Lección: el onSnapshot sobre la presencia en vivo era la mayor fuente de lecturas del sistema; se cambió por lectura puntual con refresco condicionado al foco (una pestaña olvidada abierta descargaba cientos de MB por jornada).

Tablero de decisión (tablero_decision.html)

Propósito

Cinco preguntas de pauta y ventas con veredicto escrito: ¿cómo van las ventas?, ¿qué canal trae leads?, ¿cuánto cuesta cada lead?, ¿coinciden las plataformas con el CRM?, ¿hacia dónde va la tendencia? Permiso reportes_gerenciales. Periodos 7, 14, 30 (defecto) y 60 días, siempre contra el periodo anterior del mismo largo.

Datos

whatsapp_conversations por canal, citas (sin Ruta 20, monto > 0, agrupadas por teléfono), pauta_spend, ventas_atribuidas.

Reglas y fórmulas

el día en curso se dibuja pero queda FUERA de los totales
leads por día        = total / (días - 1)
venta atribuida      = el primer contacto de cada teléfono define el canal;
                       suma citas creadas desde ese día (las anteriores no cuentan)
inversión            = meta_crc + google_crc + youtube_crc + tiktok_crc
                       (google_pendiente => Google ese día es nulo, no cero)
ROAS                 = ventas / inversión
variación            = (a - b) / b * 100        (flecha neutra si |variación| < 3 %)
veredicto de ventas  = sube > 8 % "subieron"; baja > 8 % "cayeron"
                       si cayeron y los leads también bajaron > 8 %: "el problema está antes del cierre"
                       si cayeron con leads estables: "cierra menos gente"
cruce plataforma vs CRM (Meta y Google):
   dif = crm - plataforma;  acuerdo = min(100, crm / plataforma * 100)
   brecha |dif| / plataforma: ok < 0,10; media < 0,25; alta en adelante
   (lo normal es 10 a 15 %; más de 25 % suele ser una etiqueta de origen perdida)
costo por lead       = inversión del canal / leads del canal
tendencia            = media móvil de 7 días; segunda mitad vs primera: sube > 12 %, baja < -12 %

Alertas: días sin Google, inversión desactualizada más de 1 día (roja con más de 3), más de 2 días sin datos de plataforma, ninguna venta con identificador de clic de Google ("Google puja a ciegas"), menos del 50 % de las ventas de Meta enviadas sin error.

Pulso: control de jornada (pulso.html)

Propósito

Cumplimiento de técnicos contra la convocatoria, saldo de horas, turnos en vivo, horas por día, mapa de turnos, vacaciones y teletrabajo. Permiso pulso; editar o borrar turnos exige edicion_horas.

Datos

work_sessions (lee y escribe: startTime, endTime, status working | completed | anulado, notas de ajuste, justificación de cierre, GPS de inicio y fin), work_sessions_logs (bitácora de cambios), ruteador, citas por día, flota_gps_equipos y flota_tracks (fuente gps), vacaciones, teletrabajo, empleados.

Reglas de cumplimiento

convocados = técnicos de Fumigación activos con su convocatoria más temprana del día
diffMin = primer inicio de sesión - convocatoria
   > 5: tarde;  < -10: muy temprano (se puede recortar a la convocatoria);  sin inicio: pendiente o ausente
llegada del vehículo a la central: geocerca de 100 m; última transición de afuera hacia adentro
   dentro del turno (el primer punto que ya está adentro es una salida, no una llegada)
espera al cierre = fin de sesión - llegada:  <= 15 verde, 16 a 25 amarillo, > 25 rojo, negativo = cerró en ruta
ajuste de cierre: si la espera supera 15 min se propone cerrar en llegada + 15 min con status completed
   (sin ese status la planilla no paga y la persona queda con sesión activa)
   solo si la ruta no tiene citas pendientes; sin citas registradas no se ajusta (sería cerrar a ciegas)
   justificación de hasta 140 caracteres dentro de la sesión
grupos de ruta: 1 a 10 diurnas, 11 a 17 nocturnas, 18 y 19 rurales

Horas

  • Jornada esperada según la hora de inicio: 5:00 a 11:59 son 8 h; 12:00 a 18:59 son 7 h; 19:00 a 4:59 son 6 h.
  • Almuerzo de 1 h por día para departamentos administrativos y ventas.
  • Contrato mensual: se netea la semana; contrato por horas: se suman los extras diarios.
  • Horas por día: < 6 poco, 6 a 10 normal, 10 a 12 alto, > 12 exceso.

Vacaciones

Días ganados = meses trabajados * 1,1667 (14 por año); redimidos = aprobados desde un corte más un saldo anterior; cuentan de lunes a sábado sin feriados.

Riesgos registrados: listener de vacaciones que se acumula en cada recarga, feriados duplicados en dos listas escritas a mano, cierre forzado sin bitácora.

Táctico (tactico.html) y mini táctico

Propósito

Ventas del mes en vivo contra la meta, cancelaciones, reagendamientos, ranking con comisiones de vendedores e incentivos de técnicos. Permiso comisiones. El mini táctico (mini-tactico.html, permiso mini_tactico) es la versión que ve cada vendedor con solo sus citas.

Carga

No lee nada hasta que se toca "Actualizar" (evita gráficos y listeners duplicados). Lee citas del mes por inicio_gmt6 más 4 meses de "recientes", empleados (meta base y porcentajes de comisión de oficina y calle, bandera de vendedor de leads de pauta), analitica_clientes/{teléfono} en lotes de 80, ruteador, canales y meta_ventas_mes/{YYYY-MM} (lee y escribe la meta).

Fórmulas

VERDE (ventas por día operacional):
  nuevo = teléfono sin analítica o con primera cita >= día 1 del mes
  metaPorDía = meta / días laborales (lunes a sábado sin feriado)
  avance % = round(totalMes / meta * 100)
  utilización acumulada = Σ ventas / Σ meta de días laborales * 100
  si la utilización máxima pasa de 100: "cierre estimado = meta * máx / 100"
ROJO (canceladas = Ruta 20 del mes): con monto actual y con el MÁXIMO HISTÓRICO del monto
  sacado del historial de la cita (las canceladas solían quedar en cero)
AZUL (reagendadas): días o meses distintos por los que pasó inicio_gmt6 según el historial;
  filtros de 2 a 5 días o más, o 2 a 3 meses o más
COMISIONES (desde un inicio fijo, sin Ruta 20):
  la cita es de "oficina" si algún vendedor es de leads de pauta, si no es de "calle"
  bruta = max(0, oficina / 1,13 - metaBase) * pctOficina + calle / 1,13 * pctCalle   (sin IVA del 13 %)
  neta  = bruta * (1 - 0,1083)                                                       (cargas sociales)
  comisión perdida por cancelación = (cancOf / 1,13 * pctOf + cancCalle / 1,13 * pctCalle) * 0,8917
TÉCNICOS:
  "topo": el monto completo de citas marcadas como detectadas por el técnico va a cada técnico de esa ruta
  socios ruteros: monto / nTécnicos de la ruta, incentivo 0,5 % de esa parte

Servidor

  • miniTacticoDatos (HTTP, 120 s, 1 GiB, permiso mini_tactico o comisiones): una foto por mes con máscara de campos (solo los de la cita y, del historial, los cambios de fecha, montos de canceladas y última entrada), comprimida con gzip en tactico_ventas_cache/{mes} si pesa menos de 900 KB; caché en memoria con deduplicación de pedidos simultáneos; vigencia 5 min para el mes en curso y el anterior, 6 h para meses cerrados. A cada vendedor le devuelve solo sus citas más las recientes.
  • reconcileClientesScheduled (4:30 todos los días) y su versión HTTP: recomputan analitica_clientes (primera y última cita, total de citas, total facturado, fechas) y marcan esCliente en los contactos.

Fotos de ventas y auditoría (functions/snapshot-ventas.js, auditoria_ventas.html)

Problema

El táctico calcula el mes en vivo: no existe "cómo estaba ayer" y una bajada de precio sin rastro en el historial era indetectable.

Diseño

  • snapshotVentasDiario a las 23:45 hora local (540 s, 1 GiB) fotografía el mes en curso y el anterior en snapshots_ventas/{mes}_{día}; correrlo dos veces el mismo día sobrescribe. snapshotVentasNow (HTTP, gerencia) permite rellenar meses viejos.
  • Criterio idéntico al táctico (rutas 1 a 19, monto > 0, día operacional). Lee el mes con un mes de margen a cada lado, para que una cita reagendada aparezca con su día nuevo y no como desaparecida.
  • Campos: total, citasContadas, citasEnElMes, porVendedor (con pesos), porRuta, porCanal, porDia, criterio y citasJSON (texto, no mapa: como mapa con unas 1.500 citas superaba el límite de entradas de índice por documento).
  • Señales comparando con la foto anterior, solo en citas que contaban y cuya fecha ya pasó: desapareció, pasó a Ruta 20, quedó en cero, su monto bajó más de 20.000. Con avisos manda una nota a gerencia con hasta 10 y un enlace a la auditoría.
  • Caso borde encontrado: la consulta que busca la foto anterior usa un rango de id con el mismo límite inferior y superior, que siempre devuelve vacío; la foto se guarda bien pero la alerta nocturna nunca dispara. El límite superior correcto es el prefijo más un carácter alto.

Estrategia macro (reporte_tactico.html)

Propósito

El bosque, no el árbol: tamaño, motores de crecimiento, retención, adquisición, año contra año y proyección de largo plazo. Permiso reportes_gerenciales.

Datos

La página no lee citas (antes eran unas 29.000 lecturas por apertura). Pide un rollup incremental y lee ventas_mensuales/{YYYY-MM} (unos 50 documentos) y ventas_mensuales/_cohortes. Los años previos al registro digital son totales escritos en la página.

rollupVentasMensuales (HTTP POST, permiso reportes_gerenciales, 1 instancia, 540 s)

si la última corrida tiene < 5 min: "skipped"
full si se pide, si no existe _meta o si la versión del rollup cambió; si no, incremental
incremental: recalcula desde el mes anterior; la "primera compra" de cada teléfono y sus compras
  en meses congelados salen de 10 shards (_pv_0.._pv_9) con texto "YYYY-MM:monto,..."
  (para no pasar de ~150 KB por documento)
lee citas con select(inicio_gmt6, telefono, ruta, metadata.monto, metadata.ruta) de a 10.000
por mes: ventas, nuevos (monto de quienes compran por primera vez), existentes, sinTel,
  activosCnt, nuevosCnt, citasCnt, cancelCnt, cancelMonto, pautaCrc (Σ pauta_spend del mes)
_cohortes: por mes de primera compra {n, ret[j], act[j], ven[j]};
  retMovil[t] = {act12, base, ret}: de los activos de los 12 meses anteriores, cuántos volvieron

Indicadores (último mes cerrado contra 12 meses antes)

ritmo anual             = suma de ventas de 12 meses
clientes en 12 meses    = act12
venta por cliente       = ritmo / clientes
retención anual         = ret / base
CAC                     = pauta de 12 meses / clientes nuevos de 12 meses (todos los nuevos, no solo pauta)
motores: ventas = clientes * frecuencia * ticket
         frecuencia = citas12 / clientes; ticket = ventas12 / citas12
         parte de cada factor = ln(f_actual / f_anterior) / ln(V_actual / V_anterior)   (suman exacto)
estacionalidad del mes  = ventasMes / (totalAño / 12) * 100, promedio de 3 años cerrados

Proyección y banda

r[m] = promedio de las últimas 3 razones (mes / mes anterior) para cada mes del año
g = Π r[m] - 1                           crecimiento anual implícito
est[m] = r[m] / (Π r)^(1/12)             estacionalidad pura
v_k = v_{k-1} * est[mes] * (1 + g')^(1/12), con g' = g los primeros 12 meses y 0,75 g después
el mes en curso tiene como piso lo ya agendado
banda: backtest desde cada mes de origen; error = proyectado / real - 1 por horizonte de 1 a 12 meses;
  percentiles 10 y 90 con 5 errores o más, si no ±15 %; más allá de 12 meses se ensancha con sqrt(h / 12)
MAPE y sesgo a 1, 3, 6 y 12 meses

Huecos documentados en la página: los "nuevos" del primer año digital incluyen clientes viejos (las cohortes arrancan el año siguiente), el gasto de pauta tiene registro desde una fecha posterior, y las canceladas guardan su monto solo desde un cambio reciente.

Automatizaciones: cohortes del CRM (automatizaciones.html)

Propósito

Definir cohortes como reglas vivas sobre atributos del contacto: se guarda la condición, no la lista; quien la cumple entra solo y quien deja de cumplirla sale. Permiso automatizaciones.

Datos

  • crm_atributos/{teléfono}: unos 7.500 documentos chicos que se cargan una vez y se evalúan en el navegador (conteo instantáneo).
  • crm_cohorts/{id}: nombre, tag, color, logica (todas o alguna), condiciones[{campo, op, valor}], ultimoConteo (solo referencia), creado y actualizado.

Grupos de atributos: compras (ha comprado, total facturado, ticket promedio, días desde la última compra), qué pidió (precio, agendar, urgencia, garantía, queja, mascotas, niños, empleo), plaga y lugar, conducta (días desde el último mensaje, nunca respondió, conversación profunda, nos dejó en visto, horas a la primera respuesta, horario preferido), qué mandó (imagen, documento, voz, ubicación, botón), plantillas recibidas, origen (canal, anuncio, campaña), embudo y vendedor.

Cohortes sugeridas: preguntó precio y no compró; clientes dormidos (compró hace más de 180 días); mandó ubicación y no compró; mandó foto y quedó en visto; termitas sin cerrar; conversó mucho y no cerró; nunca les escribimos; ya insistimos bastante (3 o más plantillas y en visto).

Servidor

actualizarAtributosCrmScheduled a las 4:45 (540 s, 1 GiB), incremental desde el último timestamp procesado (crm_meta/atributos, comparación estrictamente mayor). Fusión: contadores se suman, banderas con OR, listas se unen, la primera fecha se conserva, la última toma la mayor, los derivados se recalculan. Léxico por expresiones regulares para plagas, propiedad, intención, contexto y empleo. Lección: antes se restaban 2 h "por si acaso" y cada noche se sumaban dos veces los mismos mensajes. El histórico completo no cabe en 540 s y se corre con un script aparte; un backfill parcial se pide con una bandera en crm_meta.

Saldo de la IA (functions/ia-saldo.js)

Problema

Cuando la cuenta del proveedor de IA (Claude) se queda sin crédito, la API responde 400 con un mensaje de saldo insuficiente; las funciones lo convertían en un 502 genérico y nadie se enteraba (pasaron dos días sin IA).

Diseño

esSinSaldo(status, cuerpo): expresión regular sobre el cuerpo
avisarSinSaldo(origen):
  create() en ia_sin_saldo_avisos/{día local}     // si ya existe, falla: UN aviso por día
  encolar en wa_equipo_salida un texto a gerencia con la cabecera de aviso automático
    y el nombre de la primera función que falló
responderErrorIa(res, status, cuerpo, origen):
  sin saldo -> 503 { codigo: "ia_sin_saldo" } (+ aviso);  otro error -> 502

Lo usan todas las funciones con IA: mejorar texto, resumir comentarios de citas, detectar garantías, pulir el comentario del reporte, mejorar la base de conocimiento, revisar consistencia, chat del asistente interno, sugerir preguntas, comentario consolidado, atribución de WhatsApp, campos del recuperador, perfil del cliente, revisar justificaciones; y en modo solo aviso el asistente de WhatsApp, evaluaciones y actualización de preguntas frecuentes.

Escritura de vendedores (evaluarEscrituraVendedores)

Endpoint (permiso reportes_gerenciales) que toma entre 1 y 30 días (7 por defecto), hasta 140 mensajes únicos por vendedor recortados a 900 caracteres, excluye plantillas, multimedia, fallidos, textos de menos de 15 caracteres o 3 palabras y guiones repetidos (normaliza URLs y números), y los califica con Claude. Si ya hay un reporte del día con menos de 12 h lo devuelve sin pagar de nuevo. Escribe reportes_escritura/{fecha} con nota de ortografía y nota global por vendedor, que alimenta la métrica de ortografía.

Pauta y atribución: modelo general

Conceptos

Concepto Definición
Lead un documento de whatsapp_conversations cuyo id es el teléfono normalizado. Un teléfono es lead una sola vez
Canal conv.source, asignado por PRIMER TOQUE y congelado. sourceAt es la hora de ese primer toque
Subcanal conv.subSource y, para el sitio, webSubcanal
Cliente al entrar clienteAlEntrar = true si su primera cita es anterior al primer mensaje; se congela con el canal. No se usa esCliente porque se recalcula cada noche y marca también a quien compró después de escribir
Venta atribuida cita con monto > 0, fuera de Ruta 20, creada después de sourceAt
Gasto pauta_spend/{día} en colones por plataforma

Catálogo de canales (shared/canales.js, espejo idéntico en el navegador y en funciones)

Clave Etiqueta Campo de gasto Entra al reparto de leads Es lead
ad Meta meta_crc sí sí
google_ads Google Search google_crc sí sí
youtube YouTube youtube_crc sí sí
bing Bing bing_crc (previsto) sí sí
tiktok TikTok tiktok_crc sí sí
sitio_web Sitio web ninguno sí sí
directo Directo ninguno sí sí
messenger Messenger ninguno no (va a una persona) sí
saliente Saliente ninguno no no
empleo Empleo ninguno no no
nuevo_contacto_embudo Agregado a mano ninguno no no

Exporta CANALES, POR_CLAVE, CLAVES_REPARTO, CLAVES_LEAD, etiquetaCanal() y colorCanal(). Las fuentes del recuperador de cartera y los salientes se reconocen como "nuestros", no como leads.

Diagrama: del clic a la venta devuelta a la plataforma

                         ANUNCIO
   ┌───────────────┬──────────────┬──────────────────┬──────────────┐
   │ Meta CTWA     │ Google Search│ YouTube / Bing   │ TikTok       │
   │ (clic a       │ (URL con     │ (URL con gclid,  │ (wa.me con   │
   │  WhatsApp)    │  gclid/utm)  │  msclkid, utm)   │  texto fijo) │
   └──────┬────────┴──────┬───────┴────────┬─────────┴──────┬───────┘
          │               └────────┬───────┘                │
          │                        v                        │
          │         SITIO PÚBLICO (script de conversiones)  │
          │         - crea código de visita W-xxxxxx (24 h) │
          │         - POST webClick -> web_clicks/W-xxxx    │
          │           (+ gads_clicks/G-xxxx si hay gclid)   │
          │           (+ geo por IP, sin guardar la IP)     │
          │         - telemetría por vista de página        │
          │         - gtag: conversión "clic a WhatsApp",   │
          │           "llamada", "formulario"               │
          │         - grabación de sesión etiquetada con W  │
          │               │                     │           │
          │     clic a wa.me con           formulario/chat  │
          │     "Ref: W-xxxx" en el texto   POST webLead    │
          │               │                     │           │
          v               v                     v           v
   ┌──────────────────────────────────────────────────────────────┐
   │ whatsappWebhook (mensaje entrante)                            │
   │  - guarda referral del anuncio (ctwa_clid, source_id, ...)    │
   │  - detecta "Ref: W-" o "Ref: G-" en el texto                  │
   │  - asigna canal UNA vez: source, subSource, sourceAt,         │
   │    clienteAlEntrar, gclid/wbraid, campos de la visita         │
   │  - si vino de anuncio Meta: CAPI "Contact" con ctwa_clid      │
   └──────────────────────────┬───────────────────────────────────┘
                              │ el vendedor conversa y agenda
                              v
   ┌──────────────────────────────────────────────────────────────┐
   │ citas/{id}  (trigger metaCapiOnCita, solo si source == "ad")  │
   │  - cita nueva          -> CAPI "Lead"      value 0            │
   │  - cita confirmada     -> CAPI "Schedule"  value 0            │
   │  - monto 0 -> > 0      -> CAPI "Purchase"  value monto        │
   │       event_id = purchase_<citaId>                            │
   │  - monto sube          -> CAPI "Purchase" por la diferencia   │
   └──────────────────────────┬───────────────────────────────────┘
                              v
   ┌──────────────────────────────────────────────────────────────┐
   │ ventasSync (cada 6 h)                                         │
   │  citas con monto > 0, inicio entre -45 y +60 días             │
   │  + conversación con canal de pauta o sitio/directo            │
   │  + cita creada después de sourceAt                            │
   │  -> ventas_atribuidas/{citaId} {canal, monto, gclid, adId...} │
   │  -> si canal Meta y hay ctwa_clid: CAPI "Purchase" con el     │
   │     MISMO event_id (Meta deduplica contra el del trigger)     │
   │  -> concilia: cita borrada / Ruta 20 / monto 0 => anulada     │
   └──────────────┬───────────────────────────────┬───────────────┘
                  v                               v
   ┌──────────────────────────────┐   ┌──────────────────────────────┐
   │ gadsFeed (CSV con clave)     │   │ Meta: dataset de mensajería   │
   │ ventas con gclid, canal      │   │ recibe Contact, Lead,         │
   │ google_ads o youtube, no     │   │ Schedule, Purchase            │
   │ anuladas, últimos 30 días    │   │ amarrados por ctwa_clid       │
   │ -> Google Ads importa        │   └──────────────────────────────┘
   │ "Venta agendada (offline)"   │
   │ con valor en CRC             │
   └──────────────────────────────┘

   En paralelo: pautaSpendHorario (Meta API, cada hora) + pautaGoogleIngest (script dentro
   de Google Ads, cada hora) -> pauta_spend/{día}  ->  reporte_canales: CPL, conversión,
   costo por venta, ROAS.

Sitio público: código de visita y telemetría

El sitio de cara al cliente es otro repositorio, pero su script de conversiones es parte indispensable del flujo.

  1. Conversiones del navegador a Google Ads (gtag) con valor fijo: clic a WhatsApp (la primaria), clic en teléfono y formulario (en la página de gracias). Eventos de analítica equivalentes.
  2. Código de visita W-xxxxxx: 6 caracteres base36 con generador criptográfico, guardado en el navegador con vigencia de 24 h. Se crea uno nuevo si no hay vigente o si la URL trae un identificador de clic (gclid, wbraid, gbraid, msclkid, fbclid): un clic pagado siempre tiene su propio registro.
  3. Al crear la visita, POST a webClick con landing, referrer, título, utm_*, identificadores de clic, dispositivo (móvil < 768 px, tableta < 1.024, escritorio), idioma, pantalla, visitante de un año (vid), número de visita, fecha de la primera visita y variante de experimento.
  4. Cada enlace a WhatsApp recibe \n\nRef: W-xxxx en el texto precargado (o un texto por defecto con el código si no tenía) y avisa wa_click.
  5. Clic en teléfono avisa tel_click con el vendedor en turno.
  6. Telemetría por vista: segundos activos, scroll, segundos por sección, clics y campos del cotizador.
  7. Formularios y chat del sitio entregan a webLead por una función global.
  8. Herramienta de grabación de sesiones cargada al primer gesto o a los 6 s, identificada con el código W, para cruzar una grabación con su lead.

webClick (HTTP POST, CORS limitado al dominio del sitio)

  • Valida el código con ^W-[a-z0-9]{3,12}$; límite en memoria de 60 por minuto por IP (429).
  • Según evento: wa_click incrementa waClicks y guarda página y texto; tel_click guarda vendedor y número; tel guarda paginas.<idVista> = {p, seg (0 a 7200), scroll (0 a 100), secs, clics, cot} (reenviar pisa, no suma; el cantón y provincia que declara el cotizador se copian aparte).
  • Visita nueva: web_clicks/{código} con todos los campos y geo (provincia, cantón, distrito por IP). La IP no se guarda. Si hay identificador de Google, escribe también gads_clicks/G-<sufijo> por compatibilidad.
  • Experimentos declarados en código [{id, desde, pagina, variantes, metrica}]; solo se aceptan ids y variantes conocidos; un id nunca se reutiliza.

Subcanal de una visita (en orden, la primera regla que calza)

sin registro de visita                         -> sitio_sin_registro
utm_source youtube, utm_medium video, campaña de video o referrer de youtube -> anuncio_video_youtube
gclid / wbraid / gbraid                        -> clic_pagado_google
msclkid                                        -> clic_pagado_bing
utm de perfil de empresa o referrer de mapas   -> google_maps
fbclid, referrer Facebook/Instagram o utm Meta -> meta_organico
referrer de buscador Google                    -> google_organico
referrer de otros buscadores                   -> otro_buscador
TikTok                                         -> tiktok
referrer de WhatsApp                           -> whatsapp
otro utm                                       -> utm_<valor>
referrer externo                               -> sitio_<dominio>
resto                                          -> directo_al_sitio

Tipo de página de aterrizaje: portada, plaga, zona, blog, precios, planes, idioma u otra.

webLead (HTTP POST): leads del sitio sin WhatsApp

límite en memoria: 10 por minuto por IP
honeypot: si viene el campo trampa, responder ok sin hacer nada
normalizar teléfono (quitar 00; 8 dígitos => anteponer 506; extranjeros 10 a 15 dígitos)
límites persistentes en web_leads_limites:
   ip_<ip>_<minuto>: tope 10, expira a los 10 min
   tel_<teléfono>: un lead por teléfono cada 24 h, expira a las 48 h
   (si la base falla aquí, no se frena el lead)
origen normalizado: formulario principal, express por plaga, express por zona, chat, formulario
resolver la visita si ref empieza con "W-"; clienteAlEntrar desde analitica_clientes
transacción sobre whatsapp_conversations/{teléfono}:
   NUEVA: source = google_ads (visita con gclid) | bing (msclkid) | sitio_web
          subSource = chat | formulario; sourceAt; campos de visita; gclid; wbraid;
          columna del tablero = clientes si ya facturó, si no "sin asignar"; sinVentana = true
   EXISTENTE: NO toca el canal ni la visita; solo webLeadAt, contexto, seguimiento pendiente;
              la desarchiva si estaba archivada
plantilla de bienvenida: solo conversación nueva, sin otra en 24 h y con interruptor activo
burbuja de contexto como mensaje entrante con id fijo por minuto (evita duplicados)

seguimientoWebLeads (cada 15 min)

Sobre conversaciones con seguimiento pendiente (hasta 300). Respuesta humana = saliente posterior al lead que no sea del bot ni del sistema.

Regla Valor
Alerta rápida al vendedor (o a gerencia si no hay asignado) 30 min sin respuesta, entre 8 y 21 h, una sola vez
Tarea 48 h sin respuesta
Termitas aunque haya respuesta, a las 72 h tarea para confirmar que la inspección quedó agendada

Geolocalización por IP (functions/geo-cr.js)

Tabla precalculada fuera de línea cada mes cruzando una base gratuita de IP a ciudad con los polígonos de distritos del país; solo rangos nacionales. Estructura {lugares[], v4: [[inicio, fin, índice]], v6: [[hexInicio, hexFin, índice]]} con búsqueda binaria (IPv6 con enteros grandes). ubicar(ip) devuelve provincia, cantón, distrito y precision: distrito cuando el rango tiene coordenada propia (casi siempre internet fijo) y ciudad cuando solo se sabe la capital (los celulares salen por la central del operador; en pantalla "San José (sin precisión)").

Asignación del canal en el webhook de WhatsApp

Captura

Si el mensaje trae referral de anuncio se guarda en el mensaje: fuente, URL, titular, cuerpo, ctwa_clid (como referralAdId) y source_id. Del texto se extraen Ref: G-... y Ref: W-.... Idempotencia: si el mensaje ya existe por su id, no se reescribe ni se vuelve a contar (Meta reintenta). La ventana de atención se fija en 72 h si vino de anuncio y 24 h si no; solo se extiende.

Pseudocódigo

asignable = canal aún no decidido o era "saliente"

si viene de anuncio Meta:
   si asignable: source = "ad"; sourceAt; clienteAlEntrar;
                 plataforma = instagram | facebook | whatsapp | otro (por la URL del referral)
   SIEMPRE: adId = ctwa_clid; titular; cuerpo; source_id
            meta_ad_mapping/{source_id} -> nombre e id de campaña, nombre del anuncio
   si asignable y campaña, titular o cuerpo parecen de empleo: source = subSource = "empleo"
   enviar CAPI "Contact" si el mensaje no estaba guardado

si no viene de anuncio y es asignable (prioridad):
   1. "Ref: G-" en el texto       -> google_ads, o youtube si la visita W era de video; gclid de gads_clicks
   2. visita W con gclid/wbraid   -> google_ads o youtube
   3. visita W con msclkid        -> bing
   4. visita W resuelta           -> sitio_web, subSource = subcanal de la visita
   5. marcador W sin documento    -> sitio_web / sitio_sin_registro
   6. reglas de texto del primer mensaje (tabla abajo)
   7. resto                       -> directo + tipo de mensaje directo

si el canal ya estaba congelado y llega una visita W: solo se guarda webRefUltimo

Reglas de texto del primer mensaje (la primera que calza gana)

Texto source / subSource
botón de WhatsApp de la ficha de Google google_ads / boton_whatsapp_google
palabras de empleo (vacante, currículum, hoja de vida...) empleo
"vengo de Instagram / Facebook / Maps" (sin canal) / instagram, facebook, google_maps
"vengo de TikTok y quiero una cotización" tiktok / anuncio_whatsapp
"vengo de TikTok" tiktok / perfil
"vi el vehículo / el QR" (sin canal) / qr_vehiculo
factura o certificado (sin canal) / factura
"vengo del sitio web", botones de planes o del cotizador sitio_web / boton_sitio
textos precargados de anuncios Meta sin referral ad / anuncio_sin_referral
preguntas frecuentes precargadas (sin canal) / pregunta_frecuente

El canal "directo" se parte en: sin texto o llamada, pregunta precargada, saludo (3 caracteres o menos o patrón de saludo) y mensaje propio.

Casos borde

  • El canal es primer toque, pero adId (ctwa_clid) se actualiza con cada mensaje que trae referral: es último toque de Meta.
  • Antes de existir el marcador de visita, los leads de Google y del sitio cayeron en "directo".
  • Los aspirantes a empleo con referral entraban como Meta e inflaban el canal y hundían su cierre; hoy son canal empleo, se asignan a reclutamiento y quedan fuera del reparto.
  • Un número extranjero de 8 dígitos también recibe el prefijo 506 (limitación conocida).

Meta Marketing API: mapeo y gasto por campaña

Función Horario Qué hace
syncMetaAdMappingScheduled (+ HTTP) 6:00 diario, 2 reintentos recorre los anuncios de la cuenta y escribe meta_ad_mapping/{postId} = {campaignName, campaignId, adName, adId}; la versión HTTP además rellena el nombre de campaña en conversaciones de anuncio que no lo tenían
syncMetaAdSpendScheduled (+ HTTP con días 1 a 31) 7:00 diario, últimos 3 días insights por campaña y día: meta_ad_spend/{campaña}_{fecha} = {spend, impressions, clicks, messagingConversations} (conversaciones iniciadas a 7 días o conexiones de mensajería)

El source_id del referral es el post del anuncio; con el mapeo la conversación recupera su campaña.

Gasto diario (functions/pauta-spend-auto.js)

Documento pauta_spend/{YYYY-MM-DD}

fecha, tc: 450, parcial (día operacional en curso), updatedAt, autoAt, autoOrigen
meta_crc                        Meta sin campañas ajenas (excluidas por nombre con expresión regular)
meta_fb_crc, meta_ig_crc, meta_wa_crc, meta_otro_crc     suman exacto meta_crc
google_usd, google_crc          solo Search (desde que existe campaña de video separada)
google_pendiente                sin dato de Google: NUNCA se escribe 0
youtube_usd, youtube_crc
google_share {impresiones, cuota, cuota arriba, cuota absoluta arriba, perdida por presupuesto y por ranking}
ventas_crc                      foto de ventas del día
tiktok_crc                      cargado a mano (sin API)

pautaSpendHorario (minuto 5 de cada hora)

hoyOperacional = díaCR(ahora - 5 h)
días = hoy y los 3 anteriores
meta = gasto por día de Meta (si falla: no se escribe nada de Meta)
para cada día:
   parcial = día >= hoyOperacional
   meta_crc = round(Σ spend de campañas no ajenas)
   reparto por plataforma (breakdown de la cuenta entera, ajenas incluidas, así que NO se suma directo):
      tot = fb + ig + wa + otros
      fb = round(meta * fb/tot); ig = ...; wa = ...; otro = meta - fb - ig - wa   (el redondeo cae en "otro")
   si existe pauta_google_raw/{día}:
      total = Σ micros; video = micros de la campaña de video
      search = total - video
      google_usd = round(search / 1e4) / 100;  google_crc = round(search / 1e6 * 450)
      youtube_* igual con video; google_share; google_pendiente = false
   si no hay dato y el día no tenía google_crc: google_pendiente = true
   ventas_crc solo si el día no existe o seguía parcial:
      Σ monto de citas con fecha_dia == día y ruta != Ruta 20
   set(merge)

Reglas que no se rompen: Google nunca en cero por falta de dato; si Meta falla no se escribe Meta; un día cerrado no recalcula ventas_crc (las citas se mueven entre días y duplicaría la venta).

pautaGoogleIngest (HTTP POST con clave secreta)

Recibe {key, dias: {"YYYY-MM-DD": {campanas: {"<id>": micros}, share: {...}}}} desde un script que corre dentro de Google Ads cada hora (GAQL de costo por campaña y día; GAQL de cuota de impresiones solo de la campaña de Search principal). Un día sin filas es gasto cero real, porque la consulta sí corrió. Acepta como máximo 10 días, los guarda en pauta_google_raw/{día} y recalcula los días entre hoy menos 3 y hoy. Responde 403 si la clave no coincide o está vacía.

Caso borde

Un script manual viejo calculaba ventas_crc por fecha de creación (día de venta) y la función automática lo hace por día de servicio: los históricos quedaron con una definición y los nuevos con otra.

gadsClick, ventasSync y gadsFeed: la venta vuelve a la plataforma

gadsClick (HTTP POST, CORS del sitio)

{code: "G-xxx", gclid | wbraid | gbraid, url} validados con expresión regular. Escribe con create(), no con merge: el código es público y con merge cualquiera podía pisar el gclid real y envenenar el feed.

ventasSync (cada 6 horas)

citas con inicio entre ahora - 45 días y ahora + 60 días (select de campos mínimos)
candidata: monto > 0, ruta != Ruta 20, teléfono normalizado de 8+ dígitos
ya atribuidas: getAll en lotes de 100 sobre ventas_atribuidas/{citaId}
para cada candidata nueva:
   conv = whatsapp_conversations/{teléfono}; si no existe: saltar
   canal debe estar en {ad, google_ads, youtube, bing, tiktok, sitio_web, directo}
   sourceAt existe y creación de la cita >= sourceAt   (si no: "anterior al lead")
   ventas_atribuidas/{citaId} = {phone, canal, monto, gclid, googleAdsRef, adId, sourceAt,
                                 citaInicio, citaCreada, creadoEn, metaEnviado: false}
   si canal == ad:
       sin ctwa_clid: metaResp = "no atribuible", no se manda
       con ctwa_clid: CAPI Purchase; metaEnviado = sin error; metaResp = respuesta (máx. 1000 caracteres)
conciliación de los últimos 45 días:
   cita borrada, en Ruta 20 o monto 0  -> anulada: true con motivo (no se borra)
   vuelve a ser válida                 -> reactivada
   cambió el monto                     -> monto, montoPrev, montoActualizadoEn
   cambió el inicio                    -> citaInicio

Lecciones del código: la ventana pasó de 2 a 45 días porque el monto se registra días o semanas después y las plataformas optimizaban con menos ventas de las reales; metaEnviado antes quedaba en verdadero aunque Meta devolviera error y el semáforo mostraba verde mientras se rechazaban todas; lo ya importado por Google o enviado a Meta no se retira (no hay ajustes de conversión).

gadsFeed (HTTP GET con clave)

Parameters:TimeZone=-0600
Google Click ID, Conversion Name, Conversion Time, Conversion Value, Conversion Currency
<gclid>, Venta agendada (offline), YYYY-MM-DD HH:mm:ss, <monto>, CRC

Filas: ventas_atribuidas de los últimos 30 días por creadoEn, canal Google Search o YouTube, con gclid y no anuladas; la hora es la creación de la cita en hora local. Solo sube gclid (wbraid y gbraid se guardan pero no se exportan). Google Ads lo importa con una carga programada. Con la clave vacía responde 403 (antes comparar vacío con vacío dejaba el feed abierto).

Meta Conversions API (dataset de mensajería)

  • Todo va al dataset de la cuenta de WhatsApp Business con el token de WhatsApp, forzando action_source: "business_messaging", messaging_channel: "whatsapp" y el id de la cuenta de negocio. Cambiar cualquiera de los tres hace que Meta rechace el evento (con action_source: "chat" o mandándolo al píxel del sitio rebota; el token de marketing no tiene el permiso necesario).
  • Datos de usuario siempre con hash SHA-256: ph = sha256(506 + teléfono), external_id = sha256(teléfono), country = sha256("cr"), fn y ln = sha256 de la primera y última palabra del nombre en minúsculas, más ctwa_clid sin hash.
Evento Dónde Condición event_id value
Contact webhook mensaje con referral no guardado antes contact_<tel>_<idMensaje> 0
Lead trigger de cita cita nueva no ficticia, conversación con source ad lead_<citaId> 0
Schedule trigger de cita pasa a confirmada schedule_<citaId> 0
Purchase trigger de cita y ventasSync monto de 0 a > 0 purchase_<citaId> (el mismo en ambos: deduplicación) monto
Purchase (aumento) trigger de cita sube un monto ya positivo purchase_<citaId>_up_<montoNuevo> diferencia

custom_data: moneda CRC, valor, categoría, y en la compra la lista de servicios (content_ids, contents con cantidad y precio, num_items) y nombre de campaña. Existe una función para mandar al píxel del sitio desde el servidor, pero nada la llama.

Qué recibe cada plataforma

  • Meta: Contact, Lead, Schedule y Purchase, solo para leads de anuncio, amarrados por ctwa_clid.
  • Google: conversiones del navegador (clic a WhatsApp, llamada, formulario, valor fijo) y la conversión offline con valor real por gclid.
  • TikTok, Bing: sin envío de vuelta; TikTok sin gasto automático; Bing se atribuye por msclkid pero no tiene gasto ni etiqueta activos.

Reporte de canales (reporte_canales.html)

Propósito

El número oficial de pauta: leads, conversión, ventas atribuidas, inversión, CPL, costo por venta y ROAS por canal y subcanal. Permiso reportes_gerenciales.

Carga

  • Conversaciones por canal con sourceAt desde el inicio del periodo (siempre trae además 120 días para la vista semanal). Excluye empleo y clientes que ya lo eran al escribir; los muestra en "fuera del reporte" junto con los contactos propios (recuperador, salientes).
  • Subcanales: Meta por plataforma (Facebook, Instagram, WhatsApp, otro); sitio web por subcanal (orgánico, mapas, redes sin pauta, directo, otros sitios, sin dato); directo por tipo (identificó de dónde venía, pregunta precargada, sin texto, mensaje propio, saludo, sin dato). Vía: chat, formulario o WhatsApp.
  • Citas desde el inicio del periodo SIN tope superior (cuentan las futuras ya agendadas), sin Ruta 20, agrupadas por teléfono.
  • pauta_spend desde 29 días antes del periodo; Google pendiente = nulo; antes de existir el desglose por plataforma, Meta va entero a "otro".

Regla de venta atribuida

ventas del lead = citas del teléfono con monto > 0
                  y fecha de inicio >= sourceAt
                  y (sin fecha de creación o creación >= sourceAt)
monto del lead  = Σ de esas citas (incluye futuras), contado en el día de sourceAt

Se exige que la cita se haya creado después del primer mensaje porque un cliente con cita ya agendada que escribía por un anuncio le regalaba esa venta al anuncio.

Fórmulas

leads           = conversaciones del periodo
agendaron       = leads con al menos una venta
conversión %    = round(100 * agendaron / leads)
ventas          = monto atribuido
inversión       = Σ campo de gasto del canal en el periodo
CPL             = inversión / leads          (guion si inversión < 1.000 o divisor 0)
costo por venta = inversión / agendaron
ROAS            = monto / inversión          verde >= 3, ámbar 1 a 3, rojo < 1; sin pauta: "sin pauta"
cobertura       = monto atribuido que cae dentro del periodo / ventas totales del periodo

ROAS por ventana (90, 60, 30 y 7 días)

  • Los días parciales se excluyen (el día en curso infla el ROAS de 7 días y hunde las correlaciones).
  • Cada plataforma cuenta solo desde la fecha en que su atribución existe (Google Search desde que hay marcador de visita, TikTok y YouTube desde que tienen canal propio) y Google sin días pendientes.
  • Total: gasto de todas las plataformas contra el monto de todos los canales visibles (incluidos sitio, directo, Messenger): es el techo del retorno, no una atribución medida.
  • Empresa, clientes nuevos: monto de citas por día de venta de teléfonos cuya primera cita cae en la ventana, dividido por el gasto total.
  • Las ventanas de 30 días o menos se marcan como "monto todavía no maduró".

Otras secciones

  • Correlación de Pearson por día entre gasto y leads, gasto y monto, gasto y "halo" en directo: solo días cerrados con gasto, n >= 3; fuerte |r| >= 0,7, moderada >= 0,4, débil >= 0,2.
  • Tendencia: últimos 14 días contra los 14 anteriores, con 28 días o más de dato.
  • ROAS por día con media de 7 días que suma monto y gasto por separado (no promedia razones). Ventas suavizadas con los últimos 14 días sombreados como "madurando".
  • Presencia en Google: las cuotas de varios días no se promedian; se reconstruyen las impresiones elegibles (impresiones / cuota). Los topes que reporta Google (menos de 10 % y más de 90 %) se marcan como aproximados.
  • Histórico: gasto rodante de 30 días como porcentaje de las ventas, descartando ventanas con días de Google pendiente.
  • Embudo del sitio: visitas y clics a WhatsApp cruzados con leads por código de visita.
  • Salud de la atribución: frescura del último ventas_atribuidas (verde hasta 12 h, ámbar hasta 36 h, rojo después), ventas con gclid, ventas de Meta enviadas sin error.
  • Exportar CSV y una tarjeta para pegar en WhatsApp.

Inteligencia del sitio (functions/sitio-resumen.js, inteligencia_sitio.html)

resumenSitio (minuto 15 cada 3 horas; 540 s)

Recalcula 3 días en cada corrida y 35 días en la de madrugada (las citas de un lead llegan semanas después). Versión HTTP con clave y días de 1 a 120.

cargar web_clicks del periodo (o desde el inicio del experimento activo)
resultados: whatsapp_conversations donde webRef in [lote de 30] -> lead y teléfono
            ventas_atribuidas donde phone in [lote de 30], no anuladas -> monto, venta = monto > 0
fila de embudo por visita = [visita, telemetría, vio precios, usó cotizador, clic a WhatsApp, lead, venta, monto]
   vio precios: clic de abrir precios o >= 3 s en la sección de precios
   usó cotizador: algún campo usado o >= 5 s en el cotizador
web_resumen/{día} = { totales[8], dims{dimensión: {clave: [8]}}, segTot, secTot, parcial }
   dimensiones: subcanal, página, anuncio (utm_content), campaña, dispositivo, provincia, cantón,
   distrito (solo con precisión de distrito), cantón declarado, hora, día de la semana, número de visita,
   segundos (0-10, 10-30, 30-60, 60-180, 180+), scroll (cuartos), páginas vistas, sección (>= 3 s), variante
web_experimentos/{id} por VISITANTE:
   variante = la de su primera exposición; resultados = OR de todas sus visitas desde entonces, monto sumado
   quien vio las dos variantes queda "contaminado" y fuera; se guarda la suma de cuadrados del monto

Lead del sitio = conversación cuyo webRef es esa visita (solo si la visita fue el primer toque o la conversación nació en el formulario).

Pantalla

Embudo (precios y cotizador sobre visitas con telemetría; cita sobre leads; el resto sobre visitas). Tablas por corte con mínimo de visitas configurable; "fuerte" si la tasa de lead es >= 1,5 veces la base y "flojo" si es <= 0,5 veces, con 20 visitas o más.

Experimentos (bayesiano)

20.000 simulaciones Monte Carlo con Beta(1 + éxitos, 1 + n - éxitos) (muestreo Gamma de Marsaglia-Tsang)
compararTasa: P(B > A) y pérdida esperada de quedarse con cada variante
compararMonto: modelo de dos partes, Beta de probabilidad de compra por un ticket normal con
               error sqrt(var / ventas) (si no se puede, 30 % de la media); exige 2+ ventas por variante
desbalance de muestra: chi² contra 50/50 con n >= 50; p < 0,01 => alerta "no leer"
n < 200: "juntando datos"
ganador: con monto calculable, P >= 0,95 y pérdida < 2 % del valor medio por visitante;
         si no, tasa de leads con P >= 0,95 y pérdida < 0,001
tamaño necesario por variante para +20 % relativo, potencia 80 %, alfa 5 %:
   n = (1,96 + 0,84)² * (p0(1 - p0) + p1(1 - p1)) / (p1 - p0)²

ROI de pauta y campañas (vistas viejas, solo Meta)

Página Permiso Qué hace Limitaciones
roi-pauta.html roi_pauta gasto de meta_ad_spend contra conversaciones de anuncio y citas por mes; agrupa por campaña, titular o fuente; permite cargar gasto a mano (isManual: true). CPL = gasto / leads; CPA = gasto / leads con cita; ROI % = (ingresos - gasto) / gasto * 100; ROAS = ingresos / gasto no exige que la cita sea posterior al lead, no excluye empleo ni clientes que vuelven, su embudo usa un campo de estado retirado
reporte_campanas.html roi_pauta conversaciones y cierres por anuncio sin gasto; KPIs conversión a cita, ventas sobre citas, ticket, conversión a venta; cascada mensual que parte las ventas en nuevo o recurrente y pauta, orgánico o sin CRM; clics registrados por la página de redirección a WhatsApp lee todas las conversaciones sin filtro

Lección general: la regla de venta difiere entre páginas (el reporte de canales exige inicio y creación posteriores al lead; ventasSync solo creación posterior y una ventana de -45 a +60 días; las vistas viejas solo inicio posterior). El número oficial es el del reporte de canales.

Flota: recepción de puntos GPS

Propósito

Saber dónde estuvo cada vehículo, cuánto se paró, dónde y a qué velocidad, sin depender de que el técnico tenga el teléfono encendido. Es la materia prima de GPS 360, las alertas de velocidad y de cierre de ruta, el resumen de velocidad por empleado y la vista de flota.

Fuentes de posición (hay tres, conviene no mezclarlas)

Fuente Colección Quién la escribe Frecuencia Para qué se usa
GPS cableado en el vehículo (protocolo H02 por TCP) flota_tracks con fuente: "gps" y flota_gps_estado/{imei} Un receptor propio en una VM con IP fija Cada 20 s con el carro encendido, 300 s apagado GPS 360, alertas, velocidad
App Android de rastreo del técnico Base en tiempo real flota/live y flota_tracks sin fuente: "gps" La app Variable; quieto cada 5 min reporte_flota.html (casi en desuso)
Navegador del empleado dim_location track-location.js en cada carga o cambio de página Por navegación "Dónde está el equipo", auditoría del responsable de un exceso, uso de módulos

El receptor TCP (servicio aparte, no es Cloud Function)

El rastreador no habla HTTP: abre un socket TCP a una IP y puerto fijos y manda tramas de texto como *HQ,<imei>,V1,hhmmss,A,lat,N,lng,W,vel,rumbo,ddmmyy,estado#. Por eso existe un pequeño servidor Node con systemd en una VM con IP pública reservada que:

  1. Acepta la conexión, interpreta la trama H02 (hora en UTC; se convierte a Costa Rica, UTC-6 fijo sin horario de verano).
  2. Resuelve a qué vehículo pertenece el aparato en flota_gps_equipos/{imei} (clave, vehiculo, activo, asignado). Un IMEI desconocido se da de alta solo con asignado: false y guarda bajo la clave gps-<imei>. El caché de esa asignación vence a los 300 s (antes era eterno y un aparato asignado después seguía guardando bajo el nombre provisional hasta reiniciar el servicio).
  3. Filtra y acumula en memoria, y cada 120 s (o al llegar a 50 puntos) vuelca a Firestore.
  4. Escribe con credenciales de servicio de la VM, así que no pasa por reglas; desde el navegador flota_tracks (fuente gps) y flota_gps_estado son de solo lectura.

Reglas del acumulador:

Regla Valor
Distancia mínima para guardar otro punto 25 m
Aunque esté quieto, guardar un punto cada 300 s
Volcado cada 120 s o 50 puntos
Posiciones sin satélites (fix inválido) no se guardan; se marca sinFix: true en el estado
Marca de "visto" del aparato como mucho 1 escritura por minuto
Escrituras resultantes unas 700 por vehículo por día en vez de 4.300

Forma del documento de rastro (mismo formato que la app Android, para que los visores no distingan):

flota_tracks/{clave}_{YYYY-MM-DD}_b{0..3}      // bloques de 6 horas en hora local
  usuario: clave del rastro          vehiculo: nombre ("APV 03")
  imei, fuente: "gps", fecha, bloque
  puntos: [ {lat, lng, prec, vel (km/h), rum (rumbo), bat, car, ts (ms)} ]   // arrayUnion
  totalPuntos: increment(n), actualizado: serverTimestamp

flota_gps_estado/{imei}
  lat, lng, vel, rum, horaGps, horaConfiable, estadoHex, visto, sinFix, ultimoLatido, bateria

Si Firestore falla, los puntos vuelven al frente de la cola para el próximo intento. Los puntos se agrupan por documento destino porque un lote puede cruzar la medianoche o el corte de bloque.

Configuración del rastreador (por SMS, documentada en el README del receptor): número de control, APN del operador, IP y puerto del servidor, intervalo encendido (20 s) y apagado (300 s), zona horaria en UTC (no cambiarla o las horas salen corridas), modo GPRS. Requiere chip 4G (el 2G ya no existe en el país).

Lección

Antes de este receptor, la única fuente fina era la app Android y casi nadie la llevaba corriendo (un documento por día contra miles en dim_location). Por eso "Dónde está el equipo" usa dim_location y GPS 360 usa el GPS cableado.

GPS 360 (gps360.html + gps360.js)

Propósito y usuarios

El día de UN vehículo contado como línea de tiempo al estilo de una app de ubicación familiar: paradas y tramos en orden, con mapa grande y minimapas. Permiso gps360 (o los heredados gps, gps_ventas). Es la única vista de GPS desde mediados de octubre; las páginas viejas (gps.html, gps_gerencia.html, gps_ventas.html) comparten la misma lógica y permiso.

Pantallas

  • Fila de "chips" de vehículos (cara del técnico con aro del color de su ruta; sin foto, el número del carro), más "Todos".
  • Mapa grande (Leaflet sobre teselas de OpenStreetMap).
  • Línea de tiempo: tarjetas de parada (cita, central, gasolinera, sin cita) y de tramo (distancia, duración, velocidad máxima y promedio, segundos sobre 90, franja de colores por velocidad, minimapa).
  • Enlace profundo gps360.html?fecha=YYYY-MM-DD&v=<vehículo>&t=HH:MM que abre el tramo de esa hora (lo usan las alertas).
  • En vivo (solo viendo hoy): escucha flota_gps_estado con onSnapshot y refresca.

Datos que lee

flota_gps_equipos (mapa clave a vehículo), flota_tracks (por id {clave}_{fecha}_b0..3 si hay un vehículo elegido; una consulta fecha == X AND fuente == "gps" para la flota entera; el filtro por fuente lo exigen las reglas), citas del día (fecha_dia), ruteador/{fecha}_Ruta-{1..20} (técnicos, vehículos, hora de convocatoria), empleados activos (nombre y foto), gasolineras-cr.json (lista estática de gasolineras extraída de OpenStreetMap).

Reglas (compartidas con las alertas para que todo diga lo mismo)

Constante Valor Significado
PARADA_MIN 5 min quieto al menos esto para ser parada
PARADA_RADIO_M 60 m cuánto puede "bailar" el punto sin dejar de ser el mismo lugar
CERCA_CITA_M 500 m parada justificada por una cita
CENTRAL_RADIO_M 500 m parada en la central (no cuenta como sin cita)
MISMA_PARADA_M 150 m dos paradas seguidas así de cerca son una (el carro se acomodó en el parqueo)
TRAMO_MIN_M 200 m tramo más corto se descarta (es ruido del GPS)
SIN_SENAL_MS 15 min hueco que se marca como "sin señal"
VEL_EXCESO 90 km/h límite de la empresa
GASOLINERA_M / GASOLINERA_MAX_MIN 100 m / 25 min parada junto a gasolinera es combustible, salvo que dure más de 25 min

Las citas canceladas y las "ficticias" (venta registrada en ruta, no una visita) se excluyen. Las coordenadas de la cita salen de metadata.coordenadas, luego coordenadas, luego coordenadas_corregidas.

Pseudocódigo de paradas

detectarParadas(puntos ordenados por ts):
  grupo = nulo
  para cada punto p:
    si grupo y distancia(centroide(grupo), p) <= 60 m:
       actualizar centroide como promedio incremental; grupo.hasta = p.ts
    si no:
       cerrar(grupo)   // se guarda si (hasta - desde) >= 5 min
       grupo = nuevo grupo en p
  si estoy viendo hoy y el último punto tiene menos de 30 min y velocidad < 3:
       la parada sigue abierta hasta ahora
  cerrar(grupo)

clasificar(parada, vehiculo):
  si a <= 500 m de la central: "central"
  turno = ruta que llevaba ese vehículo a esa hora (por hora de convocatoria del ruteador;
          un carro puede hacer ruta de día y otra de noche)
  buscar la cita más cercana de SU ruta y la más cercana de cualquier ruta
  si la de su ruta está a <= 500 m: "cita"
  si otra está a <= 500 m: "cita" marcada como de otra ruta
  si hay gasolinera a <= 100 m: "gasolinera"
  si no: "libre" (sin cita)

unir paradas consecutivas si: misma cita, o ambas central, o a <= 150 m.
  Si una era "libre" y la otra no, la unida hereda la clase justificada.

tramos = puntos entre parada i y parada i+1; descartar si mide < 200 m.
estadísticas de tramo:
  distancia = suma haversine
  vmax = máximo de vel
  vprom = promedio de vel solo en puntos con vel >= 3 (manejando)
  segundosSobre90 = suma de dt donde el punto anterior iba > 90 y dt < 15 min
  sinSenal = hueco máximo si >= 15 min

Color por velocidad: interpolación lineal entre azul (0), celeste (30), verde (50), amarillo (70), naranja (82), rojo (90) y rojo oscuro (110). La franja del tramo usa hasta 80 rebanadas con el máximo de cada rebanada.

Alertas del GPS (functions/alertas-gps.js, alertas-gps-envio.js, alertas-gps-auditoria.js)

Propósito

Dos avisos automáticos por WhatsApp que no dependen de que alguien mire una pantalla: exceso de velocidad sostenido y cierre de ruta con paradas largas sin cita. Diseñados para no dar falsos positivos.

Funciones de servidor

Función Disparador Qué hace
alertasGpsOnTrack escritura en flota_tracks/{id} con fuente == "gps" avanza la máquina de estados de velocidad del aparato
alertasGpsBarrido cada 5 min cierra episodios de carros que dejaron de reportar, revisa rutas terminadas, libera alertas que esperaban auditoría
alertasGpsEnviar creación en alertas_gps/{id} con estado: "pendiente" arma imagen y encola el WhatsApp (2 GiB, 180 s, máximo 3 instancias)
alertasGpsReenviar actualización esperando a pendiente igual que la anterior

Configuración sin desplegar en alertas_config/gps:

activo: true
velocidad: { activo, umbral: 90, segMin: 20, picoDirecto: 100, fusionMin: 10 }
cierre:    { activo, paradaMinMin: 20, gasolineraM: 100, gasolineraMaxMin: 25 }
numero (emisor), chatId (grupo de alertas de respaldo)

Velocidad: máquina de estados por aparato

Estado persistido en alertas_gps_estado/{imei} (ultimoTs, prev, ep episodio abierto) y actualizado en transacción. La primera vez que se ve un aparato se arranca desde el último punto del lote (si no, el día del despliegue saldrían alertas viejas).

para cada punto nuevo p (filtrado: vel entre 0 y 180, ts > ultimoTs):
  si hueco con el anterior > 10 min: cerrar episodio
  implicita = distancia(prev, p) / dt * 3.6   (solo si 2 s <= dt <= 90 s)
  si p.vel > 90:
     abrir episodio si no hay
     si implicita >= 0.75 * 90 y <= 180: confirmados++
     rapidos++, ultimoRapido = p.ts, max = el de mayor vel
  si no y hay episodio:
     si p.ts - ultimoRapido >= 10 min (fusión): cerrar episodio

episodioValido(ep):
  confirmados >= 2
  y (ultimoRapido - inicio) >= 10 s
  y ((ultimoRapido - inicio) >= 20 s  o  max.vel >= 100)

Corrección de lecturas imposibles antes de validar (y también en el resumen de velocidad):

para cada punto sobre el umbral con vecinos a y b:
  si sube más de 8 km/h por segundo respecto de AMBOS vecinos: es un pico aislado,
     se reemplaza por el promedio de los vecinos
para cada punto aún sobre el umbral:
  velocidades implícitas por distancia/tiempo hacia cada vecino (2 a 60 s)
  si ninguna llega al 60 % de la leída: se reemplaza por la mayor implícita
si tras corregir ya no pasa la regla: no hubo exceso, fue el aparato

El documento alertas_gps/vel_{imei}_{inicio} (creado con create(), idempotente) guarda inicio, fin, segundos y metros SOBRE el umbral (solo tramos que terminan en punto rápido y con dt <= 60 s), máximo con posición, confirmados y los puntos.

Auditoría del responsable (antes de mandar una alerta de velocidad)

El ruteador no siempre dice qué carro hizo cada ruta (las nocturnas suelen quedar sin vehículo y la alerta caía en el diurno que lo entregó). Se cruzan tres fuentes:

Fuente Regla
Citas el GPS del carro a <= 250 m de una cita mientras su técnico tenía el ingreso marcado, en una ventana de 4 h alrededor del exceso
Teléfono dim_location del técnico (precisión <= 150 m) a <= 300 m de la posición interpolada del carro, fuera de la central, dentro de 30 min del exceso, al menos 2 puntos, separación máxima 3 min con el punto del carro
Ruteador qué ruta tenía ese carro a esa hora (con 30 min antes de la convocatoria; de madrugada se mira la ruta nocturna de ayer, contando la hora como 24 + h)

Si citas y teléfono apuntan a la misma gente, o uno de los dos sin contradicción, queda "confirmado". Si no, la alerta pasa a esperando hasta 2 h después del exceso (el barrido la revisa); vencido el plazo sin confirmar, va solo a una persona de gerencia, sin etiquetar a nadie, mostrando lo que decía el ruteador y el motivo de la duda.

Cierre de ruta

Corre en el barrido cada 5 min, para hoy y, antes de las 6:00, también para ayer (las nocturnas terminan pasada la medianoche). Cada ruta se analiza una sola vez por día (alertas_gps_cierres/{fecha}_{n}), con caché de 15 min en memoria de la instancia.

ventana de la ruta: desde 30 min antes de la convocatoria (o 5:00) hasta 30 min antes de
                    que el mismo carro arranque otro turno (o 20 h después)
si hay < 10 puntos: aún no
paradas = unir(detectar(puntos)) clasificadas en:
   central | cita (de su ruta) | otra_ruta | cita_movida | gasolinera | libre
   cita_movida: a <= 500 m de una cita cancelada o de una cita de 3 días antes a 7 después
salida = primer punto fuera de la central
si no visitó ninguna cita: aún no
regreso = parada en la central posterior a la última cita
la ruta terminó si:
   minutos en la central >= 15
   y (todas las citas visitadas o con salida marcada, o la ruta está liquidada,
      o lleva >= 90 min en la central)
fuera de ruta = paradas del viaje con clase libre (o gasolinera > 25 min),
                duración >= 20 min y al menos 3 puntos
si fuera de ruta > 0: crear alertas_gps/cierre_{fecha}_{n}

El resumen incluye salida, llegada, km, citas visitadas de total, cada parada y una línea simplificada del recorrido (objetos {a, o} porque Firestore no admite listas dentro de listas).

Envío

  1. Candado en transacción: pendiente a armando; una segunda ejecución (reintento o doble entrega) se va sin duplicar.
  2. Nombre del lugar por geocodificación inversa de OpenStreetMap (Nominatim, sin llave, una consulta por segundo, con User-Agent propio).
  3. HTML con Leaflet y teselas de Esri, fotografiado con un Chromium sin cabeza: la de velocidad cuadrada (1080) porque WhatsApp recortaba la vertical; la de cierre 1350 de alto.
  4. PNG a Storage wa_equipo/envios/alertas/{id}.png.
  5. Un documento por destino en wa_equipo_salida con id determinista alerta_{id}_{usuario} y create(), que el agente de WhatsApp de empresa consume: grupo personal de cada técnico asignado con su arroba (nunca grupos de vehículo); sin asignados, el grupo de alertas. La de velocidad lleva copia a gerencia y tono formal.
  6. Estados finales: encolado, apagado, error.

Resumen y reporte de velocidad (velocidad-resumen.js, reporte_velocidad.html)

Propósito

Ranking de manejo por empleado (no por vehículo), con atribución por evidencia. Permiso reportes_gerenciales.

Servidor

velocidadResumenDiario cada 2 horas recalcula ayer y hoy en velocidad_diaria/{fecha}. La fecha es la de la RUTA: el turno nocturno que termina de madrugada cuenta en el día que empezó.

Atribución por evidencia

para cada ruta del día (excepto la 20, que es la bolsa sin ruta real) con técnicos:
  para cada vehículo con puntos:
     nCitas = citas de esa ruta donde el carro estuvo a < 250 m entre ingreso - 10 min y salida + 10 min
     nTel   = puntos de teléfono de sus técnicos a < 300 m de la posición interpolada del carro
     si nCitas > 0  o  (nTel >= 3 y el ruteador lo tiene anotado): candidato
     peso = 3 * nCitas + nTel
  quedan los candidatos con peso >= max(2, 25 % del mayor peso)
  turno = desde la última salida de la central antes de la primera evidencia
          hasta la primera vuelta a la central después de la última
          (acotado a 90 min antes y después de la evidencia)
cada tramo de GPS se asigna al turno de más peso que lo cubre; si la ruta tiene dos
técnicos, cuenta para los dos. Lo que no cae en ningún turno va a "sinConfirmar" por vehículo.

Por empleado se guarda solo lo sumable entre días:

Campo Cálculo
seg, m segundos y metros en movimiento (vel >= 5 km/h; tramos con dt <= 120 s; velocidad implícita <= 170)
s90, s100 segundos a más de 90 y a 100 o más
eps, eps100 rachas: segundos sobre 90 >= 20 o pico >= 100, con fusión de 10 min
max, maxTs, maxVeh velocidad máxima y dónde
bins histograma de segundos en franjas de 5 km/h, 32 franjas (0 a 160)

Se descartan lecturas sobre 160 km/h y se aplica la misma corrección de saltos de las alertas.

Fórmulas del reporte

prom (km/h)        = m / seg * 3.6
% tiempo > 90      = s90 / seg * 100
min > 90 por hora  = s90 / seg * 60
P90 ("velocidad alta habitual"): percentil 90 por TIEMPO, interpolado dentro de la franja
  total = suma(bins); objetivo = 0.9 * total; acumulado = 0
  para i en franjas:
     si acumulado + bins[i] >= objetivo:
        devolver i*5 + 5 * (objetivo - acumulado) / bins[i]
     acumulado += bins[i]

Periodos: esta semana, semana pasada, últimas 4 semanas (por defecto), este mes, desde el inicio de los datos, o rango libre. Orden por defecto: % de tiempo sobre 90 descendente. No entra al ranking quien manejó menos de 1 hora en el periodo. La velocidad máxima enlaza a GPS 360 en ese minuto.

Llegada tardía (functions/alertas-llegadas.js)

Propósito

Visibilidad (no castigo) de quién debía iniciar turno y no ha marcado.

Servidor

alertasLlegadas cada 5 min de 5:00 a 22:55 hora de Costa Rica. Rastro en alertas_llegadas/{fecha}_{username}. Configuración en alertas_config/llegadas (activo, escalonesMin, incluirRuta20, ventas, tecnicos, ventanaAntesMin).

Reglas

Quién Hora esperada
Técnicos hora de convocatoria de su ruta en el ruteador (menos la ruta 20); si está en varias, la más temprana
Ventas rol de la semana en roles_ventas/{lunes}: Temprano 7:00, Diurno 8:00, Nocturno 12:00, Domingo 8:00, Sábado tarde 14:00 los sábados; cada rol solo los días que trabaja
Gerencia, administración, bodega no se avisan
para cada persona esperada con minutos de atraso >= 0:
  entrada = primer work_sessions.startTime desde (esperado - 4 h)
  si ya marcó y se le había avisado: mensaje verde con la hora real y minutos tarde; cerrar caso
  si no marcó: escalones [6, 11] min; se manda el más alto alcanzado y no enviado
     amarillo a los 6 min, rojo a los 11; después ya no insiste

Cada aviso va al grupo personal de la persona con su arroba (con una imagen fija de "llegada tarde"); el verde va solo en texto. Antes eran escalones de 0, 5 y 10 min; se suavizó.

Puntualidad (reporte_puntualidad.html)

Reporte mensual por técnico: cruza ruteador (hora de convocatoria más temprana por técnico y fecha) con work_sessions (primer inicio por día operacional).

diferencia = inicio de sesión - convocatoria (minutos)
  < -10       temprana
  -10 a +5    a tiempo
  > +5        atraso
  sin sesión  ausencia
puntualidad % = (días con entrada - atrasos) / días con entrada * 100

Las ausencias no cuentan en el porcentaje: contarlas como puntuales lo inflaba.

Dónde está el equipo (donde_esta_el_equipo.html)

Mapa de la última posición de quien tiene turno abierto. Permiso reportes_gerenciales.

  • Fuente principal: dim_location en vivo (segundos de antigüedad para quien usa el sistema).
  • Respaldo: gpsStart de la sesión de trabajo (posición al marcar entrada), dibujado punteado.
  • Frescura: fresco < 10 min, tibio < 1 h, después es historia.
  • Capas: imagen satelital de Esri con rótulos por defecto (se reconoce la bodega o la casa del cliente), alternable a calles de OpenStreetMap.

Reporte de flota (reporte_flota.html)

Vista de la app Android: posiciones en vivo desde la base en tiempo real (flota/live, solo admins listados en flota/admins) y recorrido del día desde flota_tracks (hasta 4 bloques por técnico). "Sin señal" a los 15 min (un teléfono quieto reporta cada 5 min; con un umbral menor toda la flota sale sin señal de madrugada). Batería baja < 20 %. El encuadre automático ocurre una sola vez para no dar tirones cada 25 s.

Correcciones de GPS (reporte_correcciones_gps.html, js/agenda-gps-fix.js)

  • Desde la ruta el técnico propone coordenadas nuevas para una cita: metadata.coordenadas_corregidas = {lat, lng, corregido_en, estado_revision: "pendiente", foto opcional}.
  • Gerencia (permiso reportes_gerenciales) las ve con KPIs (total, pendientes, aprobadas, rechazadas), distancia entre la original y la propuesta (baja < 100 m, media hasta 500 m, alta > 500 m) y un podio de quién más corrige.
  • Aprobar: en un lote escribe metadata.coordenadas nuevas, estado_revision: "aprobada", revisado_por, revisado_en, y el historial de la cita en su subdocumento de extras. Rechazar deja las coordenadas como estaban.
  • En agenda existe además un corrector de GPS de ingreso y salida de la cita con mapa Leaflet (colocar, mover o borrar los marcadores).

Capacitación (capacitacion.html, js/capacitacion.js, quiz-engine.js, quiz-data/)

Propósito y usuarios

Formación técnica y legal de los aplicadores con evidencia imprimible para el Ministerio de Salud: guía de lectura por curso, examen, certificado con QR verificable, acta del examen y expediente por curso. Permiso capacitacion (los quiz-*.html se mapean por prefijo, no uno por uno: había 51 archivos y solo 13 estaban mapeados, así que el resto se abría con cualquier sesión). Gerencia (permiso empleados o reportes_gerenciales) ve además el panel del equipo y la matriz de certificados.

Pantallas de capacitacion.html

  1. Bloque "Capacitación anual obligatoria" con los cursos legales y su semáforo personal.
  2. Catálogo de cursos por áreas (inducción, etiqueta, mezcla, EPP, primeros auxilios, químicos, seguridad, normativa, control de plagas, servicio, etc.), cada uno con su estado (aprobado, intentado, pendiente) y mejor nota.
  3. Tablero personal: aprobados, pendientes, motivación.
  4. Tabla de posiciones (aprobados y promedio por usuario).
  5. Mis certificados (emite el certificado de cada curso aprobado).
  6. Solo gerencia: "Cumplimiento del equipo de Fumigación" (aplicadores activos cuyo departamento empieza con "Fumigaci"), matriz persona por curso, enlace a actas, botones de expediente y bitácora.

Catálogo en el cliente

QUIZ_META[quizId] = { shortName, fullName, topics[], icon, glyph, legal? }. El campo legal trae el texto del artículo que cumple (por ejemplo "art. 11: lectura y comprensión de etiquetas"). CURSOS_LEGALES es la lista de cursos anuales obligatorios; en el código actual son 4: lectura de etiquetas, mezcla y aplicación segura, equipo de protección e higiene, intoxicación y primeros auxilios. VIGENCIA_LEGAL_DIAS = 365.

Un examen (quiz-<id>.html + quiz-data/<id>.js)

Cada página trae su guía de lectura y carga el motor compartido. El motor deriva el id del nombre del archivo y hace import('./quiz-data/<id>.js').

Formato de una pregunta:

{ scenario?: "situación narrada", context?: "dato extra", image?: "ruta",
  question: "...", options: ["A", "B", "C", "D"], correct: índice, explanation: "..." }

Los bancos de los cursos legales tienen 20 preguntas de situación; la posición de la correcta se reparte para que no quede siempre en la misma letra. Hay unos 52 bancos (plagas por especie, plaguicidas, toxicología, EPP, primeros auxilios, normativa nacional e internacional, seguridad vial, acoso laboral y sexual, ventas, WhatsApp, crisis con clientes, etc.).

Flujo:

mostrar pregunta i con barra de progreso
al elegir: bloquear opciones, pintar correcta e incorrecta, mostrar explicación
al terminar:
  pct = round(correctas / total * 100)
  aprobado = pct >= 80        // mensaje: "se necesita ceil(total*0.8) de total"
  intento = cantidad de intentos previos del usuario en ese quiz + 1
  addDoc(quizzes_entrenamiento, {
     username, nombreCompleto, quizId, quizTitulo, porcentaje, correctas, total, aprobado,
     respuestas: { q1: "B", ... },                // compatibilidad con reportes viejos
     detalle: [ { n, escenario, contexto, imagen, pregunta, opciones,
                  respondida, respondidaTexto, correcta, correctaTexto, acerto, explicacion } ],
     fecha: serverTimestamp, intento })

El detalle completo permite reimprimir el examen tal como se vio aunque el banco cambie después. Si no hay username, se usa la parte antes de la arroba del correo (antes se guardaba el correo completo y salía así en los reportes). Intentos ilimitados.

Vigencia y estado de los cursos legales

por cada (quiz, usuario) sobre todos los quizzes_entrenamiento:
  score = mejor porcentaje; lastPass = fecha del último intento con porcentaje >= 80
estadoLegal(lastPass):
  sin lastPass                 -> "Pendiente"
  vence = lastPass + 365 días
  días restantes <= 0          -> "Vencido"
  días restantes <= 30         -> "Al día" con aviso "vence en N días"
  si no                        -> "Al día hasta <fecha>"
resumen personal:
  todos al día  -> "Capacitación del año completa", con la fecha del primero que vence
  algún vencido -> rojo, "Los cursos se repiten cada 12 meses"
  si no         -> amarillo, "Te faltan N de M"

La matriz de gerencia cuenta como aprobado un puntaje >= 80.

Certificado de capacitación

  • Se arma en el cliente con los cuatro nombres de la cédula (no el usuario) y el tema visual del área.
  • Si el curso es legal, dice qué artículo cumple y "Vigente hasta" (aprobación + 365 días).
  • Antes de dibujar el QR se registra en certificados_emitidos/{serial}; si el registro falla, no se genera el certificado. Si ya se emitió para la misma aprobación, se reusa el serial.
  • Serial de capacitación: CERT-EP-<año>-<MMDD>-<6 letras del curso>-<6 del usuario>-<6 al azar>. Serial genérico: CERT-EP-<año>-<8 al azar> con alfabeto sin I, O, 0 ni 1 (se dicta por teléfono).
  • El QR apunta a verificar-certificado.html?s=<serial>.

Verificación pública (verificar-certificado.html, verificar-documentos.js)

Página sin sesión que lee certificados_emitidos/{serial} y pinta lo guardado (no lo que diga la URL): tipo capacitación (nombre, curso, nota, fecha, vigencia) o fumigación (establecimiento, dirección por provincia, cantón y distrito, servicio, fecha, vigencia). Lo que no está en la base no es un certificado. Es el mismo registro para los certificados de fumigación que emiten la agenda y el despacho (vigenciaDias por defecto 30, máximo 365).

Actas, expediente y bitácora

  • actas_quiz.html (permiso empleados o reportes_gerenciales): imprime el acta de cada intento desde detalle, con respuestas marcadas.
  • Expediente por curso (js/expediente-curso.js): un PDF con portada, contenido del curso (las mismas láminas del video cuando existen), y por colaborador su certificado y su acta. Lo ya impreso se registra en impresiones_capacitacion/{quizId}__{clave}, así el siguiente expediente sale solo con lo nuevo y se archiva detrás.
  • Bitácora de capacitación (js/bitacora-capacitacion.js): hoja de firmas por curso con los colaboradores activos de Fumigación impresos y la firma en blanco (respaldo del art. 11: asistentes, tema, fecha, quién impartió). La cédula no se imprime porque en la base hay marcadores temporales; queda la columna para escribirla a mano. Hay una bitácora equivalente de exámenes.

Videos de capacitación (/capacitacion/)

Qué hay

Una página pública (sin sesión, con metadatos de vista previa para compartir por WhatsApp) que agrupa módulos narrados por bloques: capacitación anual obligatoria, producto y riesgo químico, identificación y control de plagas, cálculo de dosis, técnica de campo, servicio y trato con el cliente, normativa. Cada curso tiene cursoN.html con su reproductor, el MP4 1920x1080 y su miniatura en /capacitacion/video/, y las láminas en /capacitacion/laminas/cursoN/. Hay 21 cursos numerados más videos de procesos (cotizaciones, turnos de ventas, espacios en ruta).

Cómo se producen (fuera del sitio)

  1. Contenido del curso en láminas 16:9 (HTML o presentación).
  2. Guion de narración por lámina.
  3. Voz sintetizada con un servicio de texto a voz (ElevenLabs), en voseo costarricense.
  4. Montaje lámina más audio a MP4 y miniatura JPG.
  5. Se publican como archivos estáticos del hosting; el expediente reutiliza las mismas láminas.

Simulador de campo (simulador_campo.html, functions/simulador-campo.js)

Propósito

El técnico atiende por voz a un cliente difícil simulado y recibe nota. Permiso capacitacion.

Escenarios

4 personajes sacados de casos reales de los bancos de preguntas (cocina recién lavada con cloro antes del gel, dueño furioso en el tercer tratamiento, casa con bebé y gatos, administradora que regatea y apura). Cada uno define titulo, banco, voz, cliente, primerMensaje, situacion, acierto (lo que se espera) y error (lo que estaría mal).

Endpoints

Endpoint Qué hace
simuladorIniciar (POST, empleado autenticado) valida tope diario, crea o actualiza el agente conversacional del escenario en el proveedor de voz (prompt del personaje, LLM Claude, duración máxima), guarda su id en config_simulador/{escenario}, registra simulador_inicios y devuelve una URL firmada de un solo uso
simuladorCalificar (POST) recupera la transcripción desde el proveedor (no la acepta del navegador), la califica con Claude y guarda el resultado como un intento de quiz

Topes del lado del servidor (se cobra por minuto hablado): 2 simulaciones por día por persona y 6 minutos por conversación.

Calificación

validar conversationId (6 a 80 caracteres alfanuméricos, guion o guion bajo)
buscar un inicio NO consumido de este usuario y escenario en las últimas 3 h
bajar la transcripción (hasta 6 intentos cada 2 s, tarda en estar lista)
rechazar si el agente no es el del escenario o si empezó > 2 min antes del inicio
reclamar la conversación con create() en simulador_conversaciones/{convId}
   y marcar el inicio consumido en el mismo lote (se califica UNA vez; si existe: 409)
pedir a Claude JSON {diagnostico, seguridad, trato, cierre (0..100), resumen, acertos[], fallos[]}
   reglas del prompt: no castigar muletillas; conversación muy corta = notas bajas;
   error de seguridad grave (p. ej. un piretroide donde hay gatos) => seguridad < 40
porcentaje = promedio de los 4 ejes
aprobado = porcentaje >= 80 Y seguridad >= 60      // la seguridad no se compensa
guardar en quizzes_entrenamiento con quizId "simulador-<escenario>",
   correctas = ejes >= 80, total = 4, detalle por eje

Lección registrada: el proveedor de voz rechaza para agentes conversacionales las voces con moderación en vivo, y eso solo se descubre al intentar crear el agente.

Conocimiento (conocimiento.html)

Base de preguntas y respuestas que alimenta al asistente interno (EcoBot). Permiso conocimiento.

  • conocimiento/{id}: tema, pregunta, respuesta, creado, creadoPor, modificado, modificadoPor, editadoPor[] (arrayUnion).
  • Botón "Mejorar" en cada campo que llama al endpoint mejorarConCerebro (Claude) con una instrucción de calibración opcional ("más corta", "más técnica").
  • Historial de conversaciones del asistente (registro_conversaciones_conocimiento) y lista de prohibiciones (prohibiciones_ecobot: temas o frases que el asistente no debe decir).

Guías

Páginas estáticas de referencia, con permiso reportes_gerenciales: guía de entrenamiento técnico, guía del módulo calendario, guía de encuestas, guía de línea gráfica, catálogo de diseños de certificados. No tienen lógica propia más allá de alguna lectura puntual de citas o stock_bodegas para ejemplos.

Cumplimiento Ministerio de Salud (cumplimiento_salud.html, cumplimiento_salud.js, cumplimiento-catalogo.js)

Propósito

Autoauditoría de los requisitos sanitarios de una empresa controladora de plagas (reglamento nacional de fumigadoras y reglamento de permisos sanitarios de funcionamiento) como lista de tareas con evidencia adjunta, más el control de hojas de seguridad (MSDS) y registros sanitarios de cada plaguicida en inventario. Permiso cumplimiento_salud (también lo usa la hoja de servicio).

Pantallas

  1. Tareas por sección con estado, responsable, fecha límite, notas y evidencias; filtros por estado.
  2. Tabla de MSDS con dos semáforos (ficha y registro) y stock actual de cada producto.
  3. Documentos: portada del paquete de MSDS (el listado de plaguicidas que pide el reglamento: nombre comercial, genérico y número de registro, numerados igual que los archivos) y resumen por producto.

Datos

cumplimiento_secciones/{clave}  clave, nombre, descripcion, orden   (doc especial "_portada" en la misma colección)
cumplimiento_items/{clave}      clave, seccion, orden, titulo, detalle, base (artículo legal),
                                estado: pendiente | en_proceso | listo | no_aplica,
                                responsable, fechaLimite, notas, actualizadoPor, actualizadoPorNombre, actualizadoEnMs
cumplimiento_evidencias/{id}    tipo: item | msds, itemClave | msdsClave, storagePath, url, nombre,
                                bytes, mime, subidoPor, subidoPorNombre, subidoEnMs
cumplimiento_msds/{clave}       clave, version, orden, codigo (del inventario), nombre, fabricante, ingrediente,
                                banda (color toxicológico), tipo, urlExterna, urlCentro, urlRegistro,
                                fechaFicha, fechaCentro, idioma, verificado, registroMS, venceRegistro,
                                tramiteRegistro, accion, copiaPath, copiaUrl, copiaNombre, ...
Storage: cumplimiento/evidencias/<itemClave>/<ms>-<nombre>
         cumplimiento/msds/<clave>/<ms>-<nombre>
Lectura en vivo, solo lectura: productos + stock_bodegas (cantidad en bodega de cada producto)

Catálogo sembrado

El catálogo vive en código y se siembra al abrir la página: crea secciones, tareas y filas de MSDS que no existan y nunca pisa lo que la gente editó (estado, notas, evidencias, copias). Las filas de MSDS traen version: si sube, se actualizan solo los campos del catálogo (fichas, fechas, registro) y se respeta la copia subida.

Seis secciones, unas 40 tareas, cada una con su base legal:

Sección Ejemplos de tareas
A. Permiso sanitario de funcionamiento clasificar la actividad en el grupo de riesgo correcto, requisitos previos al día (seguridad social, tributación, uso de suelo), solicitud con listado de plaguicidas y modelo de hoja de servicio, renovación anual con al menos 15 días de antelación, póliza de riesgos del trabajo, regencia química vigente
B. Bodega de plaguicidas cuarto exclusivo con llave, rótulo de peligro, contención impermeable >= 110 % de lo almacenado, kit de derrames, extintor vigente, carpeta de MSDS en español de no más de 3 años, solo productos con registro vigente, separar por banda toxicológica, envases originales
C. Personal, EPP y capacitación manual de procedimientos con los 6 puntos exigidos, EPP completo por aplicador, EPP guardado en la empresa, capacitación anual documentada, examen médico, filtro de contratación por prohibiciones, botiquín revisado cada mes
D. Instalaciones sanitarias baño y ducha al terminar la jornada, lavaojos en bodega y vehículo, lavado de ropa, aguas de lavado
E. Vehículo exclusivo, cabina aislada, rotulado, MSDS a bordo
F. En cada servicio hoja de servicio prestado, tiempos de reingreso, rótulos, envases vacíos

Semáforos de MSDS

fechaVigente = la más reciente entre la ficha pública y la del repositorio interno
estadoFicha:
  sin ninguna ficha ni copia                 -> rojo "SIN FICHA"
  registro vencido y sin trámite             -> "Registro MS vencido"
  fechaVigente con más de 3 años             -> amarillo "VIEJA (+3 años)"
  idioma distinto de español                 -> amarillo "EN INGLÉS"
  no verificada contra producto y formulación-> amarillo "POR VERIFICAR"
  si no                                      -> verde "AL DÍA"
semaforoRegistro:
  registro de otra autoridad (agrícola)      -> gris "NO APLICA"
  vencido con trámite de renovación          -> amarillo "EN RENOVACIÓN"
  vencido                                    -> rojo "VENCIDO <fecha>"
  con fecha futura                           -> verde "Vigente hasta <fecha>"
  certificado sin fecha legible              -> amarillo
  nada                                       -> gris "SIN CERTIFICADO"

Cruce con inventario: primero por nombre exacto (más seguro) y después por código, porque hay códigos reciclados; si dos productos comparten código se suman sus existencias.

Hoja de servicio prestado (hoja_servicio.html)

Documento imprimible por cita con los campos que exige el reglamento: producto aplicado (nombre, registro, dosis), método, medidas de seguridad antes, durante y después, condición del lugar, valoración global, técnicos, firma; original para el cliente y copia para la empresa.

Manual de procedimientos (procedimientos_salud.html, proc_mp01..19.html)

Permiso procedimientos_salud (los proc_mp* se mapean por prefijo). El índice tiene: lo que exige el Ministerio, dónde pasa cada cosa, reglas fijas de la empresa, los 19 procedimientos, matriz de cobertura (qué artículo cubre cada procedimiento), anexos y registros, control de revisiones.

Código Procedimiento Código Procedimiento
MP-01 Jornada laboral MP-11 Prohibiciones sanitarias
MP-02 Control de inventario MP-12 Capacitación y botiquines
MP-03 Transporte de plaguicidas MP-13 Triple lavado de envases
MP-04 Servicio en sitio MP-14 Residuos y producto no apto
MP-05 Aspersión, gel y espolvoreo MP-15 Calibración de equipos
MP-06 Control de roedores MP-16 Mantenimiento preventivo
MP-07 Termonebulización MP-17 Lavado de equipos
MP-08 Riesgos laborales MP-18 Protección personal
MP-09 Atención de intoxicaciones MP-19 Higiene y ropa de trabajo
MP-10 Atención de derrames

Cada página trae dos botones (js/procedimientos-salud.js): imprimir o guardar en PDF, y descargar un HTML autónomo (clona el documento, quita scripts, cabecera y elementos no imprimibles, convierte rutas de estilos, imágenes y enlaces a absolutas, y lo baja como archivo).

Plan de manejo de residuos (plan_residuos.html)

Documento independiente con el mismo permiso del manual. Secciones: los nueve recipientes, "¿dónde lo tiro?" (consulta rápida por tipo de residuo), al volver de la ruta, el área de acopio, las dos salidas (reciclaje común y devolución al proveedor), cebos retirados de las estaciones, si algo sale mal (casos), y una sección de capacitación en láminas con narración (guion de video con personajes ficticios, contador de caracteres, minutos estimados y créditos de voz) que no se imprime. Anexos: bitácora de entregas al proveedor, revisión semanal del área de acopio, rótulos para los recipientes. Regla central de la capacitación: "¿Tocó plaguicida?" decide el recipiente.

Contenido publicitario

Publicidad: banco de tomas (publicidad.html, publicidad.js, publicidad-catalogo.js)

Problema: los anuncios necesitan tomas reales del trabajo y el equipo no sabía qué grabar. Permiso publicidad.

publicidad_temas/{clave}   clave, nombre, descripcion, orden, activo
publicidad_tomas/{clave}   clave, temaClave, seg (duración de la acción limpia), que (acción),
                           como (encuadre y cámara), para (en qué anuncio sirve), orden, activo
publicidad_videos/{id}     tomaClave, temaClave, storagePath, url, nombreOriginal, bytes, tipo,
                           duracion, ancho, alto, subidoPor, subidoPorNombre, subidoEn, subidoEnMs,
                           posterPath, posterUrl (miniatura JPEG del primer segundo),
                           elegida, elegidaPor, elegidaEnMs (gerencia marca la buena)
Storage: publicidad/tomas/<tomaClave>/<id>-<nombre>, publicidad/miniaturas/<id>.jpg
  • El catálogo (unos 118 temas y tomas: llegada, inspección, aplicación, equipo, flota, etc.) se siembra por clave sin borrar ni pisar; las claves no se renombran nunca porque los videos cuelgan de ellas.
  • Subida con carga reanudable del archivo original, sin recomprimir; varios archivos en cola, uno por uno.
  • Filtros: todas, pendientes (sin video), listas, mías, elegidas. Gerencia (permiso de reportes gerenciales o departamento o cargo de gerencia, igual que las reglas) marca la toma elegida, que se ordena primero.

Videos publicitarios terminados (videos_publicitarios.html, .js)

Para que ventas y gerencia descarguen anuncios terminados y los manden a un cliente o los suban a redes. Permiso videos_publicitarios. Aquí no se sube nada desde el navegador: un script de producción carga el catálogo.

videos_publicitarios_series/{clave}    clave, nombre, descripcion, orden
videos_publicitarios_anuncios/{clave}  clave, serie, titulo, sub, duracion, copy, locucion, thumbPath,
                                       formatos: { "9x16" | "4x5" | "1x1": { storagePath, bytes, resolucion } }
Storage: videos_publicitarios/<anuncio>/<formato>.mp4 y thumb.jpg

Cada formato se explica por su uso: 9:16 historias y reels, 4:5 feeds, 1:1 marketplace y mensajería.

Videos de reuniones (videos_reuniones.html, .js)

Videos de la reunión semanal de técnicos, por partes, reproducidos como una lista que sigue sola a la próxima parte, con descarga. Solo gerencia (permiso videos_reuniones, hereda de reportes).

videos_reuniones/{id}  titulo, fecha, descripcion,
                       partes: [ { n, titulo, storagePath, thumbPath, duracion, bytes } ],
                       completa: { storagePath, thumbPath, duracion, bytes }
Storage: videos_reuniones/<id>/<archivo>.mp4 y miniaturas

Los carga un script de producción con sesión de administrador.

Problemas reportados y tickets

Botón global "Reportar un problema" (reportar-problema.js, problemas_reportados.html)

Toda página con cabecera lleva un botón de bicho. El usuario describe qué pasó (texto o dictado por reconocimiento de voz del navegador), qué esperaba y la severidad (bloquea, molesta, sugerencia), y adjunta pantallazos (captura de la página con html2canvas, archivos o pegar). El script agrega el contexto técnico solo.

reportes_problemas/{id}
  estado: abierto | resuelto, creado_en, creado_en_cliente, pagina, pagina_titulo, url,
  severidad, que_paso (mínimo 5 caracteres), que_esperaba, usuario {uid, username, nombre},
  contexto { navegador, ventana, modales abiertos y su texto, fechas y selects en pantalla,
             últimos errores de consola, errores JS, llamadas de red fallidas, extra de la página },
  pantallazos: [ { url, path, w, h, origen, bytes } ], pantallazos_pendientes
Storage: reportes_problemas/<id>/<n>.jpg

Una página puede sumar contexto propio definiendo una función global de contexto. Gerencia (permiso reportes_gerenciales) revisa en problemas_reportados.html: filtra abiertos o resueltos, copia el reporte como texto listo para pegarle a una IA de programación y lo marca resuelto. La página expone una función de exportación para leerlos desde una herramienta automatizada. Trampas conocidas: html2canvas no dibuja los íconos de fuentes de íconos y una página que no cargó datos sale casi en blanco.

Tickets internos (tickets.html, ticketssecure.js, tickets-badge.js)

Tareas entre empleados. Permiso tickets.

tickets/{id}  titulo, descripcion, creadoPor, creadoPorNombre, asignadoA, asignadoANombre,
              estado: pendiente | completada, prioridad, fechaCreacion, fechaVencimiento,
              comentarios: [ {..., tipo: "resolucion"?} ], fotos: [urls]
  • Un ticket creado por alguien de gerencia puede quedar a nombre de gerencia.
  • Vencido = pendiente con fechaVencimiento pasada; se resaltan.
  • Al completarlo se agrega un comentario de resolución; se puede reabrir.
  • La insignia del menú cuenta los pendientes asignados al usuario y se refresca cada 30 s.

Tickets de cita en rojo (functions/tickets-rojo.js)

Cuando el técnico cierra el reporte de una cita con condición general "rojo" (plaga grave), se abre un ticket al vendedor para avisar al cliente, mandarle su reporte y ofrecer un repaso a los 14 días a precio de refuerzo.

Elemento Valor
Ticket tickets_rojo/{citaId}, visible en una burbuja de la agenda
Copia pública del reporte reportes_publicos/{token}, se abre sin sesión con un enlace con token
Precio del repaso 70 % de lo pagado por defecto (config_tickets_rojo/general.pct); el vendedor lo puede cambiar en el ticket
Solo cuenta reporte cerrado (con salida registrada); mientras el técnico llena, el rojo puede ser borrador
Cierre automático ticketsRojoVerificar cada 10 min: se cierra si salió la plantilla oficial, o cualquier mensaje con el enlace del reporte o de la encuesta desde la central o un teléfono de empresa, o si ya se agendó el repaso (cita posterior del mismo cliente y teléfono)
Antes de la nota al cliente una revisión con IA asegura que el comentario visible no tenga nada grosero ni ilegal (comentario_interno nunca sale)

Tickets de cartera

En la cartera de clientes cada fila de cuenta o sucursal tiene un botón para reportar un problema estructurado (tipo de una lista fija: ubicación equivocada, nombre equivocado, sucursal duplicada, no es de esta cartera, falta sucursal, otro; nota; nivel cartera o sucursal; estado abierto o resuelto; visibilidad). Viven en la base del portal de clientes y se leen por endpoints de administración (crear, listar por estado, cambiar estado), no con el SDK del navegador.

Levantado del código de ecoplagas.online el 31 de octubre de 2026. Contacto: hola@latamcodelabs.com.