diff --git a/CLAUDE.md b/CLAUDE.md index 881d074..55ce90a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ para que un `clasp push` desde ella no pise la API con código viejo; no volver | `Code.js` | Todo el backend: API `doPost` con sesión + lógica (ver §4). Se sube a Apps Script. | | `Logo.js` | Logos en base64: `LOGO_HELGEN_AZUL` (PDF, fondo blanco, 700×200) y `LOGO_HELGEN_BLANCO`. Se sube. | | `Index.html` | Interfaz vieja de HtmlService. Se sube pero **no se usa** (`doGet` ya no la sirve; su `API_URL` es un marcador). La interfaz real es `web/cotizador/index.html`. | -| `appsscript.json` | Manifiesto: zona `America/Mexico_City`, V8, web app `executeAs: USER_DEPLOYING`, `access: ANYONE_ANONYMOUS`. Se sube. | +| `appsscript.json` | Manifiesto: zona `America/Mexico_City`, V8, web app `executeAs: USER_DEPLOYING`, `access: ANYONE_ANONYMOUS`, servicio avanzado `People` (v1, directorio). Se sube. | | `.clasp.json` | Liga la carpeta al proyecto `1GsH8Dja…`. **No cambiar el scriptId.** | | `.claspignore` | Lista blanca: solo se suben `Code.js`, `Logo.js`, `Index.html`, `appsscript.json` (excluye `anterior/`). | | `web/` | Portada (`index.html`, `logo-helgen-blanco.png`) y la interfaz real `web/cotizador/index.html` (§10). No se sube a Apps Script. `.assetsignore` excluye `Icon`/`.DS_Store`. | @@ -120,7 +120,10 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e - `getClientes()` *(UI)* — clientes que ya cotizaron, calculados desde la hoja `Cotizaciones` (`clientesDesdeFilas`). - `listarCotizaciones(busqueda)` *(UI)* — últimas 100 cotizaciones, filtro por folio/cliente/empresa/proyecto; `vendedor` = `vendedorDeFila`: nombre + apellido de la fila; en filas viejas (sin Q:S), el nombre que ese correo tiene en otras filas - (`nombresDeVendedores`, gana la más reciente); si no se conoce, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja. + (`nombresDeVendedores`, gana la más reciente); si tampoco y es @helgen.tech, el nombre del **directorio de Google** + (`completarNombresDirectorio_` → `nombreEnDirectorio_`, People API, caché 6 h `DIR_`, `-` = no encontrado; un error no + se guarda en caché); si no, el correo (`vendedor_email` o "Elaboró"). No modifica la hoja. +- `probarDirectorio()` — correr desde el editor: autoriza el permiso del directorio y muestra el nombre de quien lo ejecuta. ### 4.5 Guardar cotización - `guardarCotizacion(payload)` *(UI)* — exige vendedor verificado (si no, error de sesión); normaliza, agrega clave/unidad SAT @@ -157,7 +160,8 @@ o son lógica pura (sección 5 del archivo, sin servicios de Google, probables e normalizados. `normalizarProyecto(p)`, `nombreCarpetaProyecto(folio, cliente, nombre)` (quita `/ \ : * ? " < > |`), `proyectosDesdeFilas`, `filtrarProyectos`. - `normalizarProducto(p)`, `indiceDeClave(claves, clave)`. -- `nombresDeVendedores(filas)` (correo en minúsculas → "Nombre Apellido"), `vendedorDeFila(r, nombres)`. +- `nombresDeVendedores(filas)` (correo en minúsculas → "Nombre Apellido"), `vendedorDeFila(r, nombres)`, + `nombreDePersonaDirectorio(personas, correo)` (solo la persona con el correo exacto; nombre + apellido o `displayName`). - `terminosParaPdf(cfg, marca)` — renglones de T&C de la marca; agrega la línea de vigencia si falta. - `textoCelda(s)` — antepone `'` a texto que empiece con `= + - @` para que Sheets no lo lea como fórmula. - `validarClaimsToken(claims, clientId, dominio, ahora)` — ver §10.3. @@ -282,6 +286,9 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto. quita la configuración de web app). Ya pasó y hubo que borrar un proyecto duplicado. - Publicar siempre sobre el mismo deployment (misma `API_URL`). Nunca `clasp deploy` sin `--deploymentId`. - Si se agrega un servicio nuevo de Google, el dueño debe autorizar el permiso una vez desde el editor (`UrlFetchApp` ya está autorizado). + **Orden obligatorio:** `clasp push` → el dueño corre una función en el editor y acepta el permiso → recién entonces `clasp deploy`. + Si se publica antes de autorizar, la web app (que corre como el dueño) falla en **todas** las llamadas hasta que se autorice. + People (directorio) se agregó el 20/10/2026; se autoriza con `probarDirectorio()`. ### 6.9 Proyectos - **Opcionales al cotizar** ("Sin proyecto" por omisión). Solo se pueden elegir proyectos `Activo`; el servidor lo vuelve a revisar. diff --git a/Code.js b/Code.js index ea21231..458a817 100644 --- a/Code.js +++ b/Code.js @@ -464,6 +464,59 @@ function probarIaSat() { }); } +// ---- Directorio de Google (nombre de vendedores sin nombre en la hoja) ---- + +/** + * Agrega al mapa correo → nombre los correos @helgen.tech que no tienen nombre en la hoja, buscándolos en el + * directorio de la organización (People API). No escribe nada en la hoja. Si algo falla, el correo se queda como está. + */ +function completarNombresDirectorio_(correos, nombres) { + const pendientes = {}; + (correos || []).forEach(c => { + const correo = String(c || '').trim().toLowerCase(); + if (correo.endsWith('@' + DOMINIO_HELGEN) && !nombres[correo]) pendientes[correo] = true; + }); + Object.keys(pendientes).forEach(correo => { + const nombre = nombreEnDirectorio_(correo); + if (nombre) nombres[correo] = nombre; + }); +} + +/** + * "Nombre Apellido" de una cuenta @helgen.tech según el directorio de Google; '' si no se encuentra o falla. + * Se guarda 6 h en caché (también cuando no se encuentra, para no consultar en cada carga del Historial). + * Requiere el servicio avanzado People (appsscript.json) y que el dueño autorice el permiso del directorio una vez. + */ +function nombreEnDirectorio_(correo) { + const cache = CacheService.getScriptCache(); + const clave = 'DIR_' + correo; + const previo = cache.get(clave); + if (previo !== null) return previo === '-' ? '' : previo; // '-' = no está en el directorio + let nombre = ''; + try { + if (typeof People === 'undefined') return ''; + const r = People.People.searchDirectoryPeople({ + query: correo, readMask: 'names,emailAddresses', sources: ['DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'], + }); + nombre = nombreDePersonaDirectorio((r && r.people) || [], correo); + } catch (e) { + console.warn('Directorio sin respuesta para ' + correo + ': ' + e.message); + return ''; // no se guarda en caché: se reintenta en la siguiente carga + } + cache.put(clave, nombre || '-', 21600); + return nombre; +} + +/** Correr desde el editor: autoriza el permiso del directorio y muestra el nombre de la cuenta que lo ejecuta. */ +function probarDirectorio() { + const correo = Session.getEffectiveUser().getEmail().toLowerCase(); + CacheService.getScriptCache().remove('DIR_' + correo); + const r = People.People.searchDirectoryPeople({ + query: correo, readMask: 'names,emailAddresses', sources: ['DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'], + }); + console.log(correo + ' → "' + nombreDePersonaDirectorio((r && r.people) || [], correo) + '"'); +} + /** Pide a Claude que elija y ordene entre los candidatos. null si no hay API key o algo falla. */ function ordenarConIaSat_(texto, candidatos) { const apiKey = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY'); @@ -587,9 +640,12 @@ function listarCotizaciones(busqueda) { const nombres = nombresDeVendedores(filas); const datos = filas.reverse(); const q = String(busqueda || '').toLowerCase().trim(); - return datos + const visibles = datos .filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3] + ' ' + (r[19] || '')).toLowerCase().includes(q)) - .slice(0, 100) + .slice(0, 100); + // Correos @helgen.tech que siguen sin nombre: se buscan en el directorio de Google de la organización + completarNombresDirectorio_(visibles.map(r => r[16] || r[10]), nombres); + return visibles .map(r => ({ folio: r[0], fecha: r[1], cliente: r[2], empresa: r[3], total: r[9], elaboro: r[10], pdf: r[11], vendedor: vendedorDeFila(r, nombres), @@ -1012,6 +1068,20 @@ function vendedorDeFila(r, nombres) { return (nombres || {})[correo.toLowerCase()] || correo; } +/** + * Nombre de la persona del directorio cuyo correo coincide exacto (la búsqueda de Google también trae parecidos). + * Nombre de pila + apellido; si faltan, el nombre completo que muestra Google; '' si no hay coincidencia. + */ +function nombreDePersonaDirectorio(personas, correo) { + const buscado = String(correo || '').trim().toLowerCase(); + const p = (personas || []).find(x => (x.emailAddresses || []) + .some(e => String(e.value || '').trim().toLowerCase() === buscado)); + const n = p && (p.names || [])[0]; + if (!n) return ''; + return [n.givenName, n.familyName].map(x => String(x || '').trim()).filter(Boolean).join(' ') || + String(n.displayName || '').trim(); +} + /** Texto sin acentos, en minúsculas y con espacios simples (para comparar nombres). */ function textoNormal(s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') diff --git a/appsscript.json b/appsscript.json index 2040776..c96ab77 100644 --- a/appsscript.json +++ b/appsscript.json @@ -1,6 +1,14 @@ { "timeZone": "America/Mexico_City", - "dependencies": {}, + "dependencies": { + "enabledAdvancedServices": [ + { + "userSymbol": "People", + "serviceId": "peopleapi", + "version": "v1" + } + ] + }, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "webapp": { diff --git a/tests/sesion.test.js b/tests/sesion.test.js index 4142958..2c69b7a 100644 --- a/tests/sesion.test.js +++ b/tests/sesion.test.js @@ -97,6 +97,40 @@ const Q=(email,n,a)=>{const r=Array(19).fill('');r[16]=email;r[17]=n;r[18]=a;ret assert.equal(ctx.nombresDeVendedores([Q('a@helgen.tech','Ana','Viejo'),Q('A@helgen.tech','Ana','Nuevo'),Q('b@helgen.tech','','')])['a@helgen.tech'],'Ana Nuevo'); assert.equal(ctx.nombresDeVendedores([Q('b@helgen.tech','','')])['b@helgen.tech'],undefined,'sin nombre no entra al mapa'); assert.equal(ctx.vendedorDeFila(Q('','',''),{}),'','fila sin datos de vendedor'); + +// --- Directorio de Google (People API): correos @helgen.tech sin nombre en la hoja --- +let consultas=[], directorioFalla=false; +const PERSONAS={'leo@helgen.tech':{names:[{givenName:'Leonardo',familyName:'Ruiz Mora',displayName:'Leonardo Ruiz Mora'}],emailAddresses:[{value:'Leo@helgen.tech'}]}, + 'sinapellido@helgen.tech':{names:[{displayName:'Equipo Ventas'}],emailAddresses:[{value:'sinapellido@helgen.tech'}]}}; +ctx.People={People:{searchDirectoryPeople:o=>{consultas.push(o.query);if(directorioFalla)throw new Error('403 sin permiso'); + // Google también regresa personas parecidas: solo cuenta la que coincide exacto + return{people:[{names:[{givenName:'Leo',familyName:'Parecido'}],emailAddresses:[{value:'leo.p@helgen.tech'}]}].concat(PERSONAS[o.query]?[PERSONAS[o.query]]:[])}}}}; +const filaLeo=['HLG-2026-0010','x','Cliente','','','','',1,0.16,1.16,'leo@helgen.tech','u','[]','','Helgen','']; +const filaExt=['HLG-2026-0011','x','Cliente','','','','',1,0.16,1.16,'alguien@gmail.com','u','[]','','Helgen','']; +const filaNadie=['HLG-2026-0012','x','Cliente','','','','',1,0.16,1.16,'nadie@helgen.tech','u','[]','','Helgen','']; +cot.m.push(filaLeo.slice(),filaExt.slice(),filaNadie.slice(),filaLeo.slice()); +const antesHoja=JSON.stringify(cot.m); +const h3=post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).datos; +const vend=f=>h3.filter(c=>c.folio===f).map(c=>c.vendedor); +assert.deepEqual(vend('HLG-2026-0010'),['Leonardo Ruiz Mora','Leonardo Ruiz Mora'],'nombre + apellido del directorio'); +assert.deepEqual(vend('HLG-2026-0011'),['alguien@gmail.com'],'correo de fuera de helgen.tech no se busca'); +assert.deepEqual(vend('HLG-2026-0012'),['nadie@helgen.tech'],'no está en el directorio = correo'); +assert.deepEqual(consultas.sort(),['antes@helgen.tech','leo@helgen.tech','nadie@helgen.tech'],'una consulta por correo, sin gmail ni correos con nombre'); +assert.equal(JSON.stringify(cot.m),antesHoja,'el directorio no escribe en la hoja'); +// Caché: la segunda carga no vuelve a consultar (tampoco los no encontrados) +consultas=[]; post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}); assert.deepEqual(consultas,[]); +// Solo displayName +assert.equal(ctx.nombreDePersonaDirectorio([PERSONAS['sinapellido@helgen.tech']],'sinapellido@helgen.tech'),'Equipo Ventas'); +assert.equal(ctx.nombreDePersonaDirectorio([],'x@helgen.tech'),''); +// Si el directorio falla (sin permiso), se muestra el correo y no se guarda en caché (se reintenta) +for(const k of Object.keys(cache)) if(k.startsWith('DIR_')) delete cache[k]; +directorioFalla=true; consultas=[]; +assert.deepEqual(post({funcion:'listarCotizaciones',args:['HLG-2026-0010'],token:'t-bueno'}).datos.map(c=>c.vendedor),['leo@helgen.tech','leo@helgen.tech']); +assert.equal(cache['DIR_leo@helgen.tech'],undefined,'un error no se guarda en caché'); +// Sin servicio People (antes de habilitarlo): correo +delete ctx.People; directorioFalla=false; +assert.deepEqual(post({funcion:'listarCotizaciones',args:['HLG-2026-0010'],token:'t-bueno'}).datos.map(c=>c.vendedor),['leo@helgen.tech','leo@helgen.tech']); +cot.m.splice(cot.m.length-4,4); // Datos iniciales: usuario = correo del token const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos; assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');