From d56dd38e0241cac774c906b16c9aa48970583c4a Mon Sep 17 00:00:00 2001 From: "Efrain (Helgen)" Date: Fri, 23 Oct 2026 13:47:04 -0600 Subject: [PATCH] =?UTF-8?q?Carga=20r=C3=A1pida,=20sincronizaci=C3=B3n=20si?= =?UTF-8?q?n=20recargar,=20requisiciones=20en=20cualquier=20proyecto=20y?= =?UTF-8?q?=20clave=20de=20proyecto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bootstrap(): una sola llamada al entrar con roles y listas según el rol; las pestañas se dibujan al instante con los roles de la última sesión (localStorage, solo interfaz). getDatosIniciales queda como alias. - CacheService por versión de colección (proyectos, contratos, cotizaciones, catálogo, requisiciones, oc, pagosCobranza, pagosProveedores): doPost sube la versión tras cada escritura (con candado); las hojas que se editan a mano se revisan por huella cada pocos minutos desde la consulta de fondo. Memoria por petición para propiedades y cachés. - sincronizar(versiones, contexto): devuelve solo lo que cambió y respeta roles; la interfaz consulta cada 20 s con la pestaña visible, al cambiar de pestaña y al abrir un buscador de proyectos; si alguien está capturando, avisa en vez de repintar. Los cambios de rol se aplican sin recargar. - Requisiciones, OC y pago a proveedores en proyectos Activos y Cerrados con o sin contrato (moneda de la aceptada o MXN); Cancelados no. Cobranza sin aceptada muestra costo de OC y "Sin contrato". Pagos de clientes siguen exigiendo contrato firmado. - Clave de proyecto (columna L): folio visible PRY-AAAA-NNNN-XXX, sugerida con el nombre y editable al crear; la llave interna sigue siendo PRY-AAAA-NNNN. asignarClavesProyectos() (vista previa) y asignarClavesProyectosAplicar() asignan clave a los existentes y renombran su carpeta principal. Buscadores por clave. Co-Authored-By: Claude Opus 5.5 --- Code.js | 717 ++++++++++++++++++++++++++++-------- tests/catalogo.test.js | 2 +- tests/cobranza.test.js | 12 +- tests/compras.test.js | 52 ++- tests/cotizacion.test.js | 6 +- tests/interfaz.test.js | 97 ++++- tests/proyectos.test.js | 25 +- tests/requisiciones.test.js | 80 +++- tests/sat.test.js | 6 +- tests/sesion.test.js | 8 +- web/cotizador/index.html | 342 +++++++++++++---- 11 files changed, 1076 insertions(+), 271 deletions(-) diff --git a/Code.js b/Code.js index fa151e4..1ecdb12 100644 --- a/Code.js +++ b/Code.js @@ -86,6 +86,8 @@ function verificarTokenGoogle_(idToken) { * Son las mismas que usa google.script.run; nada más (ni setup ni funciones internas con _). */ const API_FUNCIONES = { + bootstrap: bootstrap, + sincronizar: sincronizar, getDatosIniciales: getDatosIniciales, getClientes: getClientes, listarCotizaciones: listarCotizaciones, @@ -133,7 +135,7 @@ const API_FUNCIONES = { */ function doPost(e) { let respuesta; - VENDEDOR_ = null; + VENDEDOR_ = null; VERSIONES_ = null; SIN_CACHE_ = false; MEMO_ = {}; try { let cuerpo; try { @@ -152,17 +154,339 @@ function doPost(e) { throw errorPermiso_('Tu cuenta solo tiene acceso a Requisiciones.'); } const args = Array.isArray(cuerpo.args) ? cuerpo.args : []; + const cambia = CAMBIOS_API[nombre]; + SIN_CACHE_ = !!cambia; // lo que devuelva una escritura sale de la hoja, no del caché respuesta = { ok: true, datos: API_FUNCIONES[nombre].apply(null, args) }; + // Sube la versión de lo que cambió: invalida su caché y los demás navegadores lo ven en su siguiente consulta + if (cambia && !(respuesta.datos && respuesta.datos.confirmar)) subirVersiones_(cambia); } catch (err) { respuesta = { ok: false, error: err && err.message ? err.message : String(err) }; if (err && err.sesion) respuesta.codigo = 'SESION'; else if (err && err.codigo) respuesta.codigo = err.codigo; // 'PROYECTO' (errorProyecto_), 'PERMISO' (errorPermiso_) } finally { - VENDEDOR_ = null; + VENDEDOR_ = null; VERSIONES_ = null; SIN_CACHE_ = false; MEMO_ = {}; } return ContentService.createTextOutput(JSON.stringify(respuesta)).setMimeType(ContentService.MimeType.JSON); } +// ====== 1.1 CARGA INICIAL, CACHÉ Y SINCRONIZACIÓN ====== +// bootstrap() es la única llamada al entrar: roles + listas según el rol. Las listas se guardan en CacheService con la +// versión de sus colecciones en la clave; cada escritura sube la versión (doPost, CAMBIOS_API) y así invalida su caché. +// sincronizar() recibe las versiones del navegador y devuelve solo lo que cambió, respetando los roles: la interfaz lo +// consulta cada 20 s con la pestaña visible, sin recargar. Los permisos se siguen revisando en cada función. + +const COLECCIONES = ['proyectos', 'contratos', 'cotizaciones', 'catalogo', 'requisiciones', 'oc', 'pagosCobranza', + 'pagosProveedores']; + +/** Colecciones que cambia cada función de la API: doPost sube su versión cuando termina bien. */ +const CAMBIOS_API = { + guardarCotizacion: ['cotizaciones'], guardarProducto: ['catalogo'], crearProyecto: ['proyectos'], + marcarAceptada: ['contratos'], guardarEsquema: ['contratos'], firmarContrato: ['contratos'], + desbloquearContrato: ['contratos'], registrarPago: ['pagosCobranza'], cancelarPago: ['pagosCobranza'], + cancelarOrdenCompra: ['oc', 'requisiciones'], guardarRequisicion: ['requisiciones'], + cancelarRequisicion: ['requisiciones'], cerrarRequisicion: ['requisiciones', 'oc'], + autorizarRequisicion: ['requisiciones', 'oc'], rechazarRequisicion: ['requisiciones'], + devolverRequisicion: ['requisiciones'], registrarPagoProveedor: ['pagosProveedores', 'requisiciones', 'oc'], + cancelarPagoProveedor: ['pagosProveedores', 'requisiciones', 'oc'], cerrarRequisicionCompras: ['requisiciones', 'oc'], +}; + +let VERSIONES_ = null; // versiones leídas en esta petición (doPost las limpia) +let SIN_CACHE_ = false; // durante una escritura las listas se leen de la hoja (su versión todavía no sube) +let MEMO_ = {}; // lo ya leído en esta petición (Script Properties, hojas en caché); doPost lo limpia + +/** Script Properties de esta petición (una sola lectura; roles y versiones salen de aquí). */ +function propiedades_(releer) { + if (!MEMO_.props || releer) MEMO_.props = PropertiesService.getScriptProperties().getProperties(); + return MEMO_.props; +} + +/** {proyectos: 12, contratos: 3, …} desde Script Properties (VER_). */ +function versiones_(releer) { + if (!VERSIONES_ || releer) { + const props = propiedades_(releer); + VERSIONES_ = {}; + COLECCIONES.forEach(c => { VERSIONES_[c] = Number(props['VER_' + c]) || 0; }); + } + return VERSIONES_; +} + +/** + * Sube la versión de las colecciones con el candado de script (nunca se pierde un aumento). Se llama después de escribir, + * así quien lea la versión nueva ya encuentra los datos nuevos. No llamarla con el candado tomado. + */ +function subirVersiones_(colecciones) { + if (!colecciones || !colecciones.length) return; + const lock = LockService.getScriptLock(); + if (!lock.tryLock(10000)) { console.warn('Sin candado: no subió la versión de ' + colecciones.join(', ')); return; } + try { + const props = PropertiesService.getScriptProperties(); + const nuevas = {}; + colecciones.forEach(c => { nuevas['VER_' + c] = String((Number(props.getProperty('VER_' + c)) || 0) + 1); }); + props.setProperties(nuevas); + } finally { + lock.releaseLock(); + VERSIONES_ = null; MEMO_.props = null; + } +} + +const TROZO_CACHE = 30000; // caracteres por entrada (CacheService admite 100 KB por valor; un acento ocupa 2 bytes) + +/** Lee un valor JSON guardado en trozos por guardarCache_; undefined si no está o le falta un trozo. */ +function leerCache_(clave) { + const cache = CacheService.getScriptCache(); + const meta = cache.get(clave); + if (!meta || !/^N:\d+$/.test(meta)) return undefined; + const claves = []; + for (let i = 0; i < Number(meta.slice(2)); i++) claves.push(clave + '#' + i); + const trozos = cache.getAll(claves); + if (claves.some(k => trozos[k] == null)) return undefined; + try { + return JSON.parse(claves.map(k => trozos[k]).join('')); + } catch (e) { + return undefined; + } +} + +function guardarCache_(clave, valor, ttl) { + try { + const texto = JSON.stringify(valor), datos = {}; + let n = 0; + for (let i = 0; i < texto.length; i += TROZO_CACHE) datos[clave + '#' + (n++)] = texto.slice(i, i + TROZO_CACHE); + if (n > 30) return; // demasiado grande: se seguirá leyendo de la hoja + const cache = CacheService.getScriptCache(); + if (n) cache.putAll(datos, ttl); + cache.put(clave, 'N:' + n, ttl); // al final: si la ve otra petición, los trozos ya están + } catch (e) { + console.warn('No se guardó en caché ' + clave + ': ' + e.message); + } +} + +/** Colecciones que también se editan a mano en la hoja: antes de usar su versión se revisa su huella (fuenteConHuella_). */ +const REVISAR_A_MANO_ = { proyectos: () => proyectosCache_(), contratos: () => contratosCache_(), + catalogo: () => catalogoCache_(), cotizaciones: () => clientesCache_() }; + +/** Clave de caché con las versiones de sus colecciones (si cambia una, es otra clave: la anterior ya no se usa). */ +function claveCache_(base, colecciones) { + colecciones.forEach(c => { if (REVISAR_A_MANO_[c]) REVISAR_A_MANO_[c](); }); // si alguien editó la hoja, sube su versión + const v = versiones_(); + const clave = 'K_' + base + '_' + colecciones.map(c => v[c]).join('.'); + if (clave.length <= 200) return clave; + return 'K_' + Utilities.base64EncodeWebSafe(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, clave)); +} + +/** Valor en caché para estas versiones o, si no está, fn() (y se guarda). Sin fn: solo lee (undefined si no está). */ +function enCache_(base, colecciones, ttl, fn) { + if (SIN_CACHE_) return fn ? fn() : undefined; + const clave = claveCache_(base, colecciones); + const previo = leerCache_(clave); + if (previo !== undefined || !fn) return previo; + const valor = fn(); + guardarCache_(clave, valor, ttl); + return valor; +} + +const TTL_CACHE = 21600; // 6 h (lo máximo de CacheService): cada escritura sube la versión y cambia la clave + +/** + * Como enCache_, para hojas que también se editan a mano (Estado del proyecto, precios del catálogo, filas borradas). + * El dato dura 6 h; con revisar (solo desde sincronizar, que corre en segundo plano y nadie lo espera), si pasaron más de + * cadaSeg segundos desde la última revisión se vuelve a leer la hoja: si cambió sin que la app escribiera (misma versión, + * otra huella), sube la versión para que los navegadores abiertos se enteren. Así la carga inicial nunca lee la hoja de más. + */ +function fuenteConHuella_(nombre, coleccion, cadaSeg, leer, revisar) { + if (SIN_CACHE_) return leer(); + const memo = MEMO_['F_' + nombre]; + if (memo && (!revisar || memo.revisado)) return memo.valor; + const cache = CacheService.getScriptCache(); + const v = versiones_()[coleccion]; + const previo = leerCache_('F_' + nombre + '_' + v); + if (previo !== undefined && (!revisar || cache.get('R_' + nombre))) { + MEMO_['F_' + nombre] = { valor: previo, revisado: !!revisar }; + return previo; + } + const valor = leer(); + const huella = Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, JSON.stringify(valor))); + const anterior = String(cache.get('H_' + nombre) || '').split('|'); + let version = v; + if (anterior.length === 2 && Number(anterior[0]) === v && anterior[1] !== huella) { + subirVersiones_([coleccion]); // alguien la editó directo en la hoja + version = versiones_()[coleccion]; + } + guardarCache_('F_' + nombre + '_' + version, valor, TTL_CACHE); + cache.put('H_' + nombre, version + '|' + huella, TTL_CACHE); + cache.put('R_' + nombre, '1', cadaSeg); + MEMO_['F_' + nombre] = { valor: valor, revisado: true }; + return valor; +} + +/** Todos los proyectos de la pestaña Proyectos (con carpetaId: solo para uso interno). */ +function proyectosCache_(revisar) { + return fuenteConHuella_('proyectos', 'proyectos', 300, () => { + const hoja = hojaProyectos_(); + const filas = hoja.getLastRow() > 1 + ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; + return proyectosDesdeFilas(filas); + }, revisar); +} + +/** {folio: proyecto} con los proyectos en caché. */ +function mapaProyectos_() { + const mapa = {}; + proyectosCache_().forEach(p => { mapa[p.folio.toUpperCase()] = p; }); + return mapa; +} + +/** Lo que la interfaz recibe de un proyecto (sin carpeta_id). */ +function proyectoParaLista_(p) { + return { folio: p.folio, clave: p.clave, folioVisible: p.folioVisible, nombre: p.nombre, cliente: p.cliente, + clienteId: p.clienteId, ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }; +} + +/** {folio: {estado, moneda, marca}} de todos los contratos (aceptados y firmados). Sin libro de cobranza: ninguno. */ +function contratosCache_(revisar) { + return fuenteConHuella_('contratos', 'contratos', 600, () => { + const mapa = {}, todos = contratosDelLibro_(); + Object.keys(todos).forEach(f => { mapa[f] = { estado: todos[f].estado, moneda: todos[f].moneda, marca: todos[f].marca }; }); + return mapa; + }, revisar); +} + +/** Catálogo de productos con su tipo de cambio (los precios también se editan en la hoja). */ +function catalogoCache_(revisar) { + return fuenteConHuella_('catalogo', 'catalogo', 600, () => { + const hoja = hojaCatalogo_(); + return { productos: getCatalogo_(hoja), tipoCambio: tipoCambioCatalogo_(hoja) }; + }, revisar); +} + +/** Clientes sugeridos (salen de la hoja Cotizaciones). */ +function clientesCache_(revisar) { + return fuenteConHuella_('clientes', 'cotizaciones', 600, getClientes, revisar); +} + +/** IVA y vigencia para la interfaz (la Config completa se sigue leyendo fresca al guardar). */ +function configLigera_() { + return enCache_('config', [], TTL_CACHE, () => { + const cfg = getConfig_(); + return { ivaTasa: cfg.IVA_TASA, vigenciaDias: cfg.VIGENCIA_DIAS }; + }); +} + +/** Permisos del usuario del token (solo para mostrar pestañas: cada función los vuelve a revisar). */ +function permisosUsuario_() { + if (esSoloRequisicion_()) return { cobranza: false, desbloqueo: false, compras: false, requisicion: true }; + return { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_(), requisicion: esRequisicion_() }; +} + +/** Catálogo sin precios (para sugerir descripción y unidad en requisiciones). */ +const catalogoSinPrecios_ = productos => productos.map(p => ({ clave: p.clave, descripcion: p.descripcion, unidad: p.unidad })); + +/** Corre f() y, si falla, anota el error y devuelve null (una lista que falla no tumba toda la carga). */ +function intentar_(errores, que, f) { + try { + return f(); + } catch (e) { + errores.push(que + ': ' + e.message); + return null; + } +} + +/** + * Carga inicial en una sola llamada: roles y, según el rol, catálogo, clientes y proyectos (cotizar), proyectos y "mis + * requisiciones" (requisiciones), proyectos y requisiciones (compras), proyectos y resumen si ya está en caché (cobranza). + * Con SOLO_REQUISICION: solo lo de requisiciones y el catálogo sin precios. + */ +function bootstrap() { + if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.'); + const permisos = permisosUsuario_(), solo = esSoloRequisicion_(), errores = []; + const cfg = configLigera_(), cat = catalogoCache_(); + const d = { + versiones: versiones_(), permisos: permisos, soloRequisicion: solo, usuario: usuarioActual_(), vendedor: VENDEDOR_, + ivaTasa: cfg.ivaTasa, vigenciaDias: cfg.vigenciaDias, marcas: MARCAS, + catalogo: solo ? catalogoSinPrecios_(cat.productos) : cat.productos, tipoCambio: solo ? null : cat.tipoCambio, + clientes: [], proyectos: [], errores: errores, + }; + if (!solo) { + d.clientes = intentar_(errores, 'Clientes', clientesCache_) || []; + d.proyectos = intentar_(errores, 'Proyectos', () => listarProyectos('', false)) || []; + } + // Las listas de requisiciones y el resumen de cobranza solo van si ya están en caché (lo normal): con el caché vacío + // (nadie entró en horas) costarían varios segundos, y la interfaz los pide al abrir su pestaña + if (permisos.requisicion) { + d.req = { proyectos: intentar_(errores, 'Proyectos de requisiciones', () => listarProyectosRequisicion('')), + mias: intentar_(errores, 'Mis requisiciones', () => misRequisiciones(true)) }; + } + if (permisos.compras) { + d.com = { proyectos: intentar_(errores, 'Proyectos de compras', () => listarProyectosCompras('')), + requisiciones: intentar_(errores, 'Requisiciones', () => listarRequisiciones('', '', true)) }; + } + if (permisos.cobranza) { + d.cob = { proyectos: intentar_(errores, 'Proyectos de cobranza', () => listarProyectosCobranza('')), + resumen: intentar_(errores, 'Resumen de cobranza', () => resumenCobranzaGeneral(true)) }; + } + return d; +} + +/** Nombre anterior de la carga inicial (por si un navegador tiene la interfaz vieja en caché). */ +function getDatosIniciales() { + return bootstrap(); +} + +/** + * Consulta ligera de la interfaz (cada 20 s con la pestaña visible, al cambiar de pestaña y al abrir un buscador de + * proyectos). vc = versiones que tiene el navegador; ctx = lo que está viendo {cobProyecto, cobResumen, comProyecto, req, + * rcBuscar, rcEstado}. Devuelve las versiones, los permisos y solo las listas o vistas que cambiaron y que su rol puede ver. + */ +function sincronizar(vc, ctx) { + if (!VENDEDOR_) throw errorSesion_('Inicia sesión con tu cuenta @' + DOMINIO_HELGEN + '.'); + vc = vc || {}; ctx = ctx || {}; + const permisos = permisosUsuario_(), solo = esSoloRequisicion_(), errores = []; + // Hojas que se editan a mano: cada pocos minutos (para todos) se releen y, si cambiaron, sube su versión + intentar_(errores, 'Proyectos', () => proyectosCache_(true)); + if (permisos.cobranza || permisos.compras) intentar_(errores, 'Contratos', () => contratosCache_(true)); + intentar_(errores, 'Catálogo', () => catalogoCache_(true)); + if (!solo) intentar_(errores, 'Clientes', () => clientesCache_(true)); + const v = versiones_(true); + const cambio = c => String(vc[c] == null ? '' : vc[c]) !== String(v[c]); + const r = { versiones: v, permisos: permisos, soloRequisicion: solo, cambios: COLECCIONES.filter(cambio), errores: errores }; + if (!r.cambios.length) return r; + const algo = (...cs) => cs.some(cambio); + + if (algo('catalogo')) { + const cat = catalogoCache_(); + r.catalogo = solo ? catalogoSinPrecios_(cat.productos) : cat.productos; + if (!solo) r.tipoCambio = cat.tipoCambio; + } + if (!solo) { + if (algo('cotizaciones')) r.clientes = intentar_(errores, 'Clientes', clientesCache_); + if (algo('proyectos', 'contratos')) r.proyectos = intentar_(errores, 'Proyectos', () => listarProyectos('', false)); + } + if (permisos.requisicion) { + if (algo('proyectos')) r.reqProyectos = intentar_(errores, 'Proyectos de requisiciones', () => listarProyectosRequisicion('')); + if (algo('requisiciones', 'oc', 'pagosProveedores', 'proyectos')) r.misReq = intentar_(errores, 'Mis requisiciones', misRequisiciones); + } + if (permisos.compras) { + if (algo('proyectos', 'contratos')) r.comProyectos = intentar_(errores, 'Proyectos de compras', () => listarProyectosCompras('')); + if (algo('requisiciones', 'oc', 'pagosProveedores', 'proyectos')) { + r.comRequisiciones = intentar_(errores, 'Requisiciones', () => listarRequisiciones(ctx.rcBuscar || '', ctx.rcEstado || '')); + if (ctx.req) r.requisicion = intentar_(errores, 'Requisición', () => getRequisicion(ctx.req)); + } + if (ctx.comProyecto && algo('oc', 'contratos', 'proyectos')) { + r.comprasProyecto = intentar_(errores, 'Órdenes del proyecto', () => getComprasProyecto(ctx.comProyecto)); + } + } + if (permisos.cobranza) { + if (algo('proyectos', 'contratos')) r.cobProyectos = intentar_(errores, 'Proyectos de cobranza', () => listarProyectosCobranza('')); + if (ctx.cobProyecto && algo('proyectos', 'contratos', 'cotizaciones', 'pagosCobranza', 'oc')) { + r.cobranzaProyecto = intentar_(errores, 'Cobranza del proyecto', () => getCobranzaProyecto(ctx.cobProyecto)); + } + if (ctx.cobResumen && algo('proyectos', 'contratos', 'pagosCobranza', 'oc')) { + r.resumen = intentar_(errores, 'Resumen de cobranza', () => resumenCobranzaGeneral()); + } + } + return r; +} + // ====== 2. INSTALACIÓN (ejecutar 1 sola vez) ====== const TERMINO_VIGENCIA = @@ -654,44 +978,6 @@ function guardarProducto(p) { } } -/** Llamada por la app al cargar. */ -function getDatosIniciales() { - const cfg = getConfig_(); - const hojaCat = hojaCatalogo_(); - if (esSoloRequisicion_()) { - // Solo la pestaña Requisiciones: catálogo sin precios (para sugerir descripción y unidad), sin clientes ni proyectos - return { - soloRequisicion: true, ivaTasa: cfg.IVA_TASA, marcas: MARCAS, - catalogo: getCatalogo_(hojaCat).map(p => ({ clave: p.clave, descripcion: p.descripcion, unidad: p.unidad })), - tipoCambio: null, clientes: [], proyectos: [], usuario: usuarioActual_(), vendedor: VENDEDOR_, - permisos: { cobranza: false, desbloqueo: false, compras: false, requisicion: true }, - }; - } - return { - ivaTasa: cfg.IVA_TASA, - vigenciaDias: cfg.VIGENCIA_DIAS, - marcas: MARCAS, - catalogo: getCatalogo_(hojaCat), - tipoCambio: tipoCambioCatalogo_(hojaCat), - clientes: getClientes(), - proyectos: proyectosParaInicio_(), - usuario: usuarioActual_(), - vendedor: VENDEDOR_, - // Solo para mostrar u ocultar las pestañas Cobranza y Compras; cada función lo vuelve a revisar - permisos: { cobranza: esCobranza_(), desbloqueo: esAdminDesbloqueo_(), compras: esCompras_(), requisicion: esRequisicion_() }, - }; -} - -/** Proyectos activos para el buscador. Si fallan, la app carga igual (se puede cotizar "Sin proyecto"). */ -function proyectosParaInicio_() { - try { - return listarProyectos('', false); - } catch (e) { - console.warn('Sin lista de proyectos: ' + e.message); - return []; - } -} - /** * Clientes que ya cotizaron, tomados de la hoja Cotizaciones. * Si se borran sus filas de la base de datos, dejan de sugerirse. @@ -714,14 +1000,18 @@ function listarCotizaciones(busqueda) { const nombres = nombresDeVendedores(filas); const datos = filas.reverse(); const q = String(busqueda || '').toLowerCase().trim(); + // Folio del proyecto con su clave (PRY-AAAA-NNNN-XXX); si no se puede leer Proyectos, el de la hoja + let proyectos = {}; + try { proyectos = mapaProyectos_(); } catch (e) { /* el historial carga igual */ } + const proyectoDe = r => { const p = proyectos[String(r[19] || '').toUpperCase()]; return p ? p.folioVisible : r[19]; }; return datos - .filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3] + ' ' + (r[19] || '')).toLowerCase().includes(q)) + .filter(r => !q || (r[0] + ' ' + r[2] + ' ' + r[3] + ' ' + (proyectoDe(r) || '')).toLowerCase().includes(q)) .slice(0, 100) .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), // Filas anteriores a Proyectos (o cotizadas sin proyecto) - proyecto: r[19] || 'Sin proyecto', + proyecto: proyectoDe(r) || 'Sin proyecto', moneda: r[20] || 'MXN', // filas anteriores a USD: pesos })); } @@ -750,7 +1040,8 @@ function guardarCotizacion(payload) { const cache = CacheService.getScriptCache(); const claveCache = idSolicitud ? 'REQ_' + idSolicitud : ''; // Proyecto opcional: vacío = "Sin proyecto" (el PDF va a la carpeta del año, como antes) - const proyectoFolio = String((payload && payload.proyectoFolio) || '').trim().toUpperCase(); + // Siempre la llave interna PRY-AAAA-NNNN en la columna T, aunque llegue el folio con clave + const proyectoFolio = folioInterno(payload && payload.proyectoFolio); const lock = LockService.getScriptLock(); // Evita folios duplicados si dos personas guardan a la vez @@ -861,9 +1152,10 @@ function logoDataUrl_(fileId) { const ENCABEZADOS_PROYECTOS = [ 'Folio', 'Fecha', 'Nombre', 'Cliente', 'cliente_id', 'Ubicación', 'Estado', 'carpeta_id', - 'vendedor_email', 'vendedor_nombre', 'vendedor_apellido' + 'vendedor_email', 'vendedor_nombre', 'vendedor_apellido', 'clave' ]; const COL_ESTADO_PROYECTO = 7; // G +const COL_CLAVE_PROYECTO = 12; // L const COL_CARPETA_PROYECTO = 8; // H const ESTADOS_PROYECTO = ['Activo', 'Cerrado', 'Cancelado']; // Sin "Pagos" ni "Órdenes de compra": comprobantes y órdenes van en las carpetas de cobranza y compras (no visibles para @@ -899,21 +1191,19 @@ function hojaProyectos_() { if (ENCABEZADOS_PROYECTOS.some((h, i) => actuales[i] !== h)) { hoja.getRange(1, 1, 1, n).setValues([ENCABEZADOS_PROYECTOS]) .setFontWeight('bold').setBackground('#2C3C5B').setFontColor('#FFFFFF'); + hoja.getRange(1, COL_CLAVE_PROYECTO, hoja.getMaxRows(), 1).setNumberFormat('@'); // la clave es texto (columna L, al final) } return hoja; } -/** Proyectos con búsqueda por folio, cliente o nombre. Por omisión solo los Activos; los más recientes primero. */ +/** Proyectos con búsqueda por folio, clave, cliente o nombre. Por omisión solo los Activos; los más recientes primero. */ function listarProyectos(busqueda, incluirInactivos) { - const hoja = hojaProyectos_(); - const filas = hoja.getLastRow() > 1 - ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; // El buscador de cotizaciones (solo activos) no muestra proyectos con contrato firmado - const firmados = incluirInactivos ? {} : foliosConContratoFirmado_(); - return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => !firmados[p.folio.toUpperCase()]), - busqueda, incluirInactivos, 50) - .map(p => ({ folio: p.folio, nombre: p.nombre, cliente: p.cliente, clienteId: p.clienteId, - ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha })); + const contratos = incluirInactivos ? {} : contratosCache_(); + return filtrarProyectos(proyectosCache_().filter(p => { + const c = contratos[p.folio.toUpperCase()]; + return !(c && c.estado === ESTADO_FIRMADO); + }), busqueda, incluirInactivos, 50).map(proyectoParaLista_); } /** Error de proyecto inexistente o no Activo: doPost lo manda con codigo 'PROYECTO' y la interfaz limpia el campo. */ @@ -925,7 +1215,7 @@ function errorProyecto_(mensaje) { /** Proyecto por folio (con su número de fila), o null. */ function buscarProyecto_(hoja, folio) { - const buscado = String(folio || '').trim().toUpperCase(); + const buscado = folioInterno(folio); if (!buscado || hoja.getLastRow() < 2) return null; const filas = hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues(); const lista = proyectosDesdeFilas(filas); @@ -936,9 +1226,10 @@ function buscarProyecto_(hoja, folio) { } /** - * Alta de proyecto (desde "Nuevo proyecto" en la interfaz). p = {nombre, ubicacion, cliente:{nombre, empresa, rfc}, - * idSolicitud}. Folio PRY-AAAA-NNNN bajo el mismo candado que las cotizaciones; primero crea la carpeta y luego la - * fila, para que no quede un proyecto registrado sin carpeta. + * Alta de proyecto (desde "Nuevo proyecto" en la interfaz). p = {nombre, ubicacion, clave, cliente:{nombre, empresa, rfc}, + * idSolicitud}. Folio PRY-AAAA-NNNN bajo el mismo candado que las cotizaciones (la clave de 3 letras va en la columna L y + * se muestra como PRY-AAAA-NNNN-XXX); primero crea la carpeta y luego la fila, para que no quede un proyecto registrado + * sin carpeta. */ function crearProyecto(p) { const vendedor = VENDEDOR_; @@ -962,17 +1253,18 @@ function crearProyecto(p) { const hoja = hojaProyectos_(); const folio = siguienteFolio_(PREFIJO_PROYECTO, hoja, 'CONSECUTIVO_PROY_'); const ahora = new Date(); - const carpeta = crearCarpetaProyecto_(folio, datos, ahora.getFullYear()); + const carpeta = crearCarpetaProyecto_(folioVisible(folio, datos.clave), datos, ahora.getFullYear()); const t = textoCelda; hoja.appendRow([ folio, ahora, t(datos.nombre), t(datos.cliente), t(datos.clienteId), t(datos.ubicacion), 'Activo', carpeta.getId(), - t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), + t(vendedor.email), t(vendedor.nombre), t(vendedor.apellido), datos.clave, ]); SpreadsheetApp.flush(); - const resultado = { folio: folio, nombre: datos.nombre, cliente: datos.cliente, clienteId: datos.clienteId, - ubicacion: datos.ubicacion, estado: 'Activo', url: carpeta.getUrl() }; + const resultado = { folio: folio, clave: datos.clave, folioVisible: folioVisible(folio, datos.clave), nombre: datos.nombre, + cliente: datos.cliente, clienteId: datos.clienteId, ubicacion: datos.ubicacion, estado: 'Activo', + fecha: Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy'), url: carpeta.getUrl() }; if (claveCache) cache.put(claveCache, JSON.stringify(resultado), 21600); // 6 h return resultado; } finally { @@ -980,6 +1272,52 @@ function crearProyecto(p) { } } +/** + * Correr desde el editor: clave para los proyectos que no tienen (la sugerida con su nombre) y su carpeta principal (por + * carpeta_id) pasa de "PRY-AAAA-NNNN — …" a "PRY-AAAA-NNNN-XXX — …". Esta función solo muestra en el registro lo que haría; + * asignarClavesProyectosAplicar() lo hace. Una carpeta renombrada a mano (que no empieza con su folio) no se toca. Las + * carpetas de Cobranza y Compras se quedan con el folio corto: se buscan por nombre. + */ +function asignarClavesProyectos(aplicar) { + const cambios = conCandado_(() => { + const hoja = hojaProyectos_(); + const filas = hoja.getLastRow() > 1 + ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; + const lista = []; + filas.forEach((r, i) => { + const folio = String(r[0] || '').trim(); + if (!folio || String(r[11] || '').trim()) return; // ya tiene clave: no cambia + const clave = claveSugerida(r[2]); + const x = { fila: i + 2, folio: folio, nombre: String(r[2] || ''), clave: clave, carpeta: '(sin carpeta)' }; + try { + const carpeta = DriveApp.getFolderById(String(r[7] || '')); + const actual = carpeta.getName(); + if (actual === folio || actual.indexOf(folio + ' ') === 0) { + x.carpeta = actual + ' → ' + folioVisible(folio, clave) + actual.slice(folio.length); + if (aplicar) carpeta.setName(folioVisible(folio, clave) + actual.slice(folio.length)); + } else { + x.carpeta = actual + ' (renombrada a mano: no se toca)'; + } + } catch (e) { + x.carpeta = '(no se encontró la carpeta: ' + e.message + ')'; + } + if (aplicar) hoja.getRange(i + 2, COL_CLAVE_PROYECTO).setNumberFormat('@').setValue(clave); + lista.push(x); + }); + if (aplicar) SpreadsheetApp.flush(); + return lista; + }); + if (aplicar && cambios.length) subirVersiones_(['proyectos']); + Logger.log((aplicar ? 'APLICADO' : 'VISTA PREVIA (no cambió nada; para aplicar corre asignarClavesProyectosAplicar)') + + ' · ' + cambios.length + ' proyecto(s) sin clave'); + cambios.forEach(x => Logger.log(x.folio + ' "' + x.nombre + '" → ' + folioVisible(x.folio, x.clave) + ' · carpeta: ' + x.carpeta)); + return cambios; +} + +function asignarClavesProyectosAplicar() { + return asignarClavesProyectos(true); +} + /** Carpeta "Proyectos" junto a la base de datos (en la carpeta maestra). Se crea sola y se guarda en PROYECTOS_FOLDER_ID. */ function carpetaProyectos_() { const props = PropertiesService.getScriptProperties(); @@ -1007,7 +1345,7 @@ function subcarpeta_(padre, nombre) { return padre.createFolder(nombre); } -/** Proyectos/AAAA/ — — / con sus subcarpetas. */ +/** Proyectos/AAAA/ — — / con sus subcarpetas. */ function crearCarpetaProyecto_(folio, datos, anio) { const carpetaAnio = subcarpeta_(carpetaProyectos_(), String(anio)); const carpeta = carpetaAnio.createFolder(nombreCarpetaProyecto(folio, datos.cliente, datos.nombre)); @@ -1028,7 +1366,7 @@ function subcarpetaProyecto_(proyecto, nombreSub) { } catch (e) { carpeta = null; } if (!carpeta) { const anio = Number(String(proyecto.folio).split('-')[1]) || new Date().getFullYear(); - carpeta = crearCarpetaProyecto_(proyecto.folio, proyecto, anio); + carpeta = crearCarpetaProyecto_(folioVisible(proyecto.folio, proyecto.clave), proyecto, anio); proyecto.carpetaId = carpeta.getId(); if (proyecto.hoja && proyecto.fila) proyecto.hoja.getRange(proyecto.fila, COL_CARPETA_PROYECTO).setValue(carpeta.getId()); } @@ -1073,7 +1411,7 @@ function errorPermiso_(mensaje) { /** ¿El usuario del token está en la lista de correos de la propiedad (rol)? Sin token (editor) o sin la propiedad: no. */ function enRol_(propiedad) { if (!VENDEDOR_) return false; - const lista = correosDeLista(PropertiesService.getScriptProperties().getProperty(propiedad), DOMINIO_HELGEN); + const lista = correosDeLista(propiedades_()[propiedad], DOMINIO_HELGEN); return lista.indexOf(String(VENDEDOR_.email).toLowerCase()) !== -1; } @@ -1269,19 +1607,26 @@ function datosCobranza_(libro, folio) { return { hojas: { contratos: hC, esquemas: hE, pagos: hP }, contrato: contrato, etapas: etapas, pagos: pagos }; } -/** {"PRY-2026-0001": , …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */ -function contratosFirmados_() { +/** {"PRY-2026-0001": , …} de todos los contratos (aceptados y firmados), leídos de la hoja. Sin libro: ninguno. */ +function contratosDelLibro_() { const libro = libroCobranzaSiExiste_(); const mapa = {}; if (!libro) return mapa; const hoja = libro.getSheetByName('Contratos'); if (!hoja) return mapa; filasCobranza_(hoja, 'Contratos').forEach((r, i) => { - if (String(r[7]) === ESTADO_FIRMADO) mapa[String(r[0]).toUpperCase()] = contratoDesdeFila_(r, i + 2); + if (String(r[0] || '').trim()) mapa[String(r[0]).toUpperCase()] = contratoDesdeFila_(r, i + 2); }); return mapa; } +/** {"PRY-2026-0001": , …} de proyectos con contrato firmado. Sin libro de cobranza: ninguno. */ +function contratosFirmados_() { + const todos = contratosDelLibro_(), mapa = {}; + Object.keys(todos).forEach(f => { if (todos[f].estado === ESTADO_FIRMADO) mapa[f] = todos[f]; }); + return mapa; +} + /** {"PRY-2026-0001": , …}: se usa como "¿tiene contrato firmado?". */ function foliosConContratoFirmado_() { return contratosFirmados_(); @@ -1333,8 +1678,7 @@ function vistaCobranza_(libro, proyecto) { const c = d.contrato; const resumen = c && d.etapas.length ? resumenCobranza(c, d.etapas, d.pagos) : null; return { - proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado, - ubicacion: proyecto.ubicacion }, + proyecto: datosProyectoCompras_(proyecto), cotizaciones: cotizacionesDeProyecto_(proyecto.folio), contrato: c ? { cotizacion: c.cotizacion, marca: c.marca, moneda: c.moneda, subtotal: c.subtotal, iva: c.iva, total: c.total, estado: c.estado, aceptadaPor: c.aceptadaPor, fechaAceptacion: c.fechaAceptacion, @@ -1350,7 +1694,7 @@ function vistaCobranza_(libro, proyecto) { puedePagar: !!c && c.estado === ESTADO_FIRMADO, puedeDesbloquear: !!c && c.estado === ESTADO_FIRMADO && esAdminDesbloqueo_(), // Solo llega al rol de cobranza (vistaCobranza_ se llama después de exigirCobranza_) - margen: c ? margenDeContrato_(c) : null, + margen: c ? margenDeContrato_(c) : margenSinContrato_(proyecto.folio), metodos: METODOS_PAGO, hoy: hoyIso_(), }; @@ -1369,6 +1713,16 @@ function margenDeContrato_(contrato) { } } +/** Sin cotización aceptada no hay margen: solo el costo de las órdenes de compra vigentes, por moneda. null si no hay. */ +function margenSinContrato_(proyectoFolio) { + try { + const ordenes = ordenesDeProyecto_(libroComprasSiExiste_(), proyectoFolio).filter(o => o.estado !== OC_CANCELADA); + return ordenes.length ? { sinContrato: true, costos: costosDeOrdenes(ordenes, 'MXN') } : null; + } catch (e) { + return { error: 'No se pudo calcular el costo de las órdenes: ' + e.message }; + } +} + /** Candado de script (el mismo de folios) alrededor de una función. */ function conCandado_(f) { const lock = LockService.getScriptLock(); @@ -1383,10 +1737,10 @@ function conCandado_(f) { /** Proyectos para el buscador de Cobranza (todos los estados), con el estado de su contrato. */ function listarProyectosCobranza(busqueda) { exigirCobranza_(); - const libro = libroCobranza_(); - const estados = {}; - filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos').forEach(r => { estados[String(r[0]).toUpperCase()] = String(r[7]); }); - return listarProyectos(busqueda, true).map(p => Object.assign(p, { contrato: estados[p.folio.toUpperCase()] || '' })); + // La primera vez que alguien de cobranza entra se crean la carpeta y el libro (después ya no se abre aquí) + if (!PropertiesService.getScriptProperties().getProperty(AREA_COBRANZA.propLibro)) libroCobranza_(); + const contratos = contratosCache_(); + return listarProyectos(busqueda, true).map(p => Object.assign(p, { contrato: (contratos[p.folio.toUpperCase()] || {}).estado || '' })); } function getCobranzaProyecto(folio) { @@ -1399,17 +1753,21 @@ function getCobranzaProyecto(folio) { * Resumen de cobranza de todos los proyectos con contrato (solo rol de cobranza; solo lee). Lee una vez Contratos, Esquemas, * Pagos, Proyectos y las órdenes de compra, y calcula con resumenGeneralCobranza (las mismas cifras que la vista de cada proyecto). */ -function resumenCobranzaGeneral() { +function resumenCobranzaGeneral(soloSiHay) { exigirCobranza_(); + // En caché por versiones (contratos, pagos, órdenes y proyectos); soloSiHay = no calcularlo si no está (carga inicial) + return enCache_('resumenCob', ['proyectos', 'contratos', 'pagosCobranza', 'oc'], TTL_CACHE, + soloSiHay ? null : resumenCobranzaDeHojas_) || null; +} + +function resumenCobranzaDeHojas_() { const libro = libroCobranza_(); const contratos = filasCobranza_(hojaCobranza_(libro, 'Contratos'), 'Contratos') .map((r, i) => contratoDesdeFila_(r, i + 2)).filter(c => c.proyectoFolio); const esquemas = filasCobranza_(hojaCobranza_(libro, 'Esquemas'), 'Esquemas') .map(r => ({ proyectoFolio: String(r[0]), orden: Number(r[1]), etapa: String(r[2]), porcentaje: Number(r[3]) })); const pagos = filasCobranza_(hojaCobranza_(libro, 'Pagos'), 'Pagos').map((r, i) => pagoDesdeFila_(r, i + 2)); - const hoja = hojaProyectos_(); - const proyectos = proyectosDesdeFilas(hoja.getLastRow() > 1 - ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []); + const proyectos = proyectosCache_(); const libroCompras = libroComprasSiExiste_(); const ordenes = libroCompras ? filasDeArea_(hojaCompras_(libroCompras, 'OrdenesCompra'), HOJAS_COMPRAS, 'OrdenesCompra') @@ -1780,31 +2138,40 @@ function proyectoParaCompras_(folio) { return proyecto; } +/** + * Moneda y línea de negocio de las órdenes de un proyecto: las de su cotización aceptada (firmada o no). Sin aceptada, + * MXN y sin línea. marcarAceptada no deja aceptar otra moneda si ya hay órdenes vigentes (el margen nunca mezcla monedas). + */ +function monedaDeProyecto_(contrato) { + return { moneda: contrato ? contrato.moneda : 'MXN', marca: contrato ? contrato.marca : '', sinContrato: !contrato, + contrato: contrato ? contrato.estado : '' }; +} + +/** Datos del proyecto que ven compras y requisiciones (sin montos del contrato). */ +function datosProyectoCompras_(p) { + return { folio: p.folio, clave: p.clave || '', folioVisible: p.folioVisible || p.folio, nombre: p.nombre, cliente: p.cliente, + estado: p.estado, ubicacion: p.ubicacion }; +} + /** Respuesta para "Órdenes por proyecto" en la pestaña Compras: proyecto, moneda y órdenes. Sin montos del contrato ni margen. */ function vistaCompras_(libro, proyecto) { - const c = contratosFirmados_()[proyecto.folio.toUpperCase()] || null; + const m = monedaDeProyecto_(contratosDelLibro_()[proyecto.folio.toUpperCase()] || null); return { - proyecto: { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado, - ubicacion: proyecto.ubicacion }, - moneda: c ? c.moneda : '', - marca: c ? c.marca : '', + proyecto: datosProyectoCompras_(proyecto), + moneda: m.moneda, + marca: m.marca, + sinContrato: m.sinContrato, ordenes: ordenesDeProyecto_(libro, proyecto.folio).map(o => { const x = Object.assign({}, o); delete x.fila; return x; }).reverse(), hoy: hoyIso_(), }; } -/** Proyectos Activos con contrato firmado (con su moneda; sin montos), para ver sus órdenes. */ +/** Proyectos Activos y Cerrados (con o sin contrato; sin montos), con la moneda de sus órdenes, para ver sus órdenes. */ function listarProyectosCompras(busqueda) { exigirCompras_(); - const firmados = contratosFirmados_(); - const hoja = hojaProyectos_(); - const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; - return filtrarProyectos(proyectosDesdeFilas(filas).filter(p => firmados[p.folio.toUpperCase()]), busqueda, false, 50) - .map(p => { - const c = firmados[p.folio.toUpperCase()]; - return { folio: p.folio, nombre: p.nombre, cliente: p.cliente, ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha, - moneda: c.moneda, marca: c.marca }; - }); + const contratos = contratosCache_(); + return filtrarProyectos(proyectosCache_().filter(p => p.estado !== 'Cancelado'), busqueda, true, 50) + .map(p => Object.assign(datosProyectoCompras_(p), { fecha: p.fecha }, monedaDeProyecto_(contratos[p.folio.toUpperCase()] || null))); } function getComprasProyecto(folio) { @@ -1816,16 +2183,16 @@ function getComprasProyecto(folio) { /** * Emite una orden de compra con su PDF (solo desde autorizarRequisicion, dentro del candado). d0 = {proveedor: * {razonSocial, rfc, contacto, correo, telefono}, fechaEntrega, lugarEntrega, condicionesPago, moneda, tipoCambio, ivaTasa, - * renglones: [{clave, descripcion, unidad, cantidad, costo}], notas}. Solo en proyectos Activos con contrato firmado. La línea - * de negocio sale del contrato (nunca del navegador); el costo es el capturado (nunca el precio de venta del catálogo). + * renglones: [{clave, descripcion, unidad, cantidad, costo}], notas}. En proyectos Activos y Cerrados, con o sin contrato. + * La moneda del proyecto y la línea de negocio salen de la cotización aceptada (sin aceptada: MXN y sin línea; nunca del + * navegador); el costo es el capturado (nunca el precio de venta del catálogo). */ function emitirOrdenCompra_(libro, d0, proyecto, reqFolio) { const cfg = getConfig_(); - if (proyecto.estado !== 'Activo') { - throw new Error('El proyecto ' + proyecto.folio + ' está ' + proyecto.estado.toLowerCase() + ': no admite órdenes de compra.'); + if (proyecto.estado === 'Cancelado') { + throw new Error('El proyecto ' + proyecto.folio + ' está cancelado: no admite órdenes de compra.'); } - const c = contratosFirmados_()[proyecto.folio.toUpperCase()]; - if (!c) throw new Error('Solo se emiten órdenes de compra en proyectos con contrato firmado.'); + const c = monedaDeProyecto_(contratosDelLibro_()[proyecto.folio.toUpperCase()] || null); const oc = normalizarOrdenCompra(d0, { moneda: c.moneda, ubicacion: proyecto.ubicacion, hoy: hoyIso_(), ivaTasa: cfg.IVA_TASA }); const hoja = hojaCompras_(libro, 'OrdenesCompra'); @@ -1837,7 +2204,7 @@ function emitirOrdenCompra_(libro, d0, proyecto, reqFolio) { // PDF (razón social y datos fiscales de Helgen, iguales en Helgen y Helgen Energy) const html = construirHtmlOrdenCompra(Object.assign({}, oc, { folio: folio, fecha: Utilities.formatDate(ahora, 'America/Mexico_City', 'dd/MM/yyyy'), proveedor: proveedor, - proyectoFolio: proyecto.folio, reqFolio: reqFolio, + proyectoFolio: folioVisible(proyecto.folio, proyecto.clave), reqFolio: reqFolio, elaboro: elaboro + (elaboro === VENDEDOR_.email ? '' : ' (' + VENDEDOR_.email + ')'), }), cfg, logoDataUrl_(cfg.LOGO_FILE_ID)); const pdf = Utilities.newBlob(html, 'text/html', folio + '.html').getAs('application/pdf') @@ -1846,7 +2213,7 @@ function emitirOrdenCompra_(libro, d0, proyecto, reqFolio) { const t = textoCelda; hoja.appendRow([ - folio, textoFecha_(hoyIso_()), proyecto.folio, MARCAS[c.marca], t(proveedor.id), t(proveedor.razonSocial), t(proveedor.rfc), + folio, textoFecha_(hoyIso_()), proyecto.folio, MARCAS[c.marca] || '', t(proveedor.id), t(proveedor.razonSocial), t(proveedor.rfc), t(proveedor.contacto), t(proveedor.correo), t(proveedor.telefono), textoFecha_(oc.fechaEntrega), t(oc.lugarEntrega), t(oc.condicionesPago), oc.moneda, oc.ivaTasa, oc.subtotal, oc.iva, oc.total, oc.monedaProyecto, oc.tipoCambio, oc.subtotalProyecto, JSON.stringify(oc.renglones), t(oc.notas), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email), @@ -1855,7 +2222,7 @@ function emitirOrdenCompra_(libro, d0, proyecto, reqFolio) { ]); bitacora_(libro, 'Emitir orden de compra', proyecto.folio, folio + (reqFolio ? ' · ' + reqFolio : '') + ' · ' + proveedor.razonSocial + ' · ' + formatoMoneda(oc.total) + ' ' + oc.moneda + (oc.tipoCambio ? ' · TC ' + oc.tipoCambio + ' = ' + - formatoMoneda(oc.subtotalProyecto) + ' ' + oc.monedaProyecto + ' sin IVA' : '') + ' · ' + MARCAS[c.marca], '', HOJAS_COMPRAS); + formatoMoneda(oc.subtotalProyecto) + ' ' + oc.monedaProyecto + ' sin IVA' : '') + ' · ' + (MARCAS[c.marca] || 'sin contrato'), '', HOJAS_COMPRAS); return { folio: folio, url: archivo.getUrl(), total: oc.total, moneda: oc.moneda }; } @@ -1928,7 +2295,7 @@ const PREFIJO_REQ = 'REQ', PREFIJO_PGP = 'PGP'; const SUBCARPETA_REQUISICIONES = 'Requisiciones'; /** Funciones que puede llamar una cuenta de SOLO_REQUISICION (todo lo demás se le niega en doPost). */ -const FUNCIONES_SOLO_REQUISICION = ['getDatosIniciales', 'listarProyectosRequisicion', 'misRequisiciones', +const FUNCIONES_SOLO_REQUISICION = ['bootstrap', 'sincronizar', 'getDatosIniciales', 'listarProyectosRequisicion', 'misRequisiciones', 'guardarRequisicion', 'cancelarRequisicion', 'cerrarRequisicion']; /** ¿Solo puede usar requisiciones? (SOLO_REQUISICION; manda sobre cualquier otro rol). */ @@ -2028,13 +2395,9 @@ function escribirRenglonesReq_(libro, folio, version, renglones) { renglones.forEach((r, i) => hoja.appendRow([folio, version, i + 1, t(r.descripcion), r.cantidad, t(r.unidad), t(r.notas)])); } -/** {folio: nombre} de los proyectos (para mostrar el nombre sin el cliente). */ +/** {folio: proyecto} (para mostrar el nombre sin el cliente y el folio con clave). */ function nombresDeProyectos_() { - const hoja = hojaProyectos_(); - const filas = hoja.getLastRow() > 1 ? hoja.getRange(2, 1, hoja.getLastRow() - 1, ENCABEZADOS_PROYECTOS.length).getValues() : []; - const mapa = {}; - proyectosDesdeFilas(filas).forEach(p => { mapa[p.folio.toUpperCase()] = p; }); - return mapa; + return mapaProyectos_(); } /** Bytes de un archivo adjunto ({nombre, datos base64}); null si no viene. */ @@ -2056,31 +2419,38 @@ function reqParaSolicitante_(req, ren, eventos, proyectos) { const p = proyectos[req.proyectoFolio.toUpperCase()]; return { folio: req.folio, fecha: req.fecha, proyectoFolio: req.proyectoFolio, proyectoNombre: p ? p.nombre : '', - estado: req.estado, motivo: req.motivo, notasRecepcion: req.notasRecepcion, + proyectoVisible: p ? p.folioVisible : req.proyectoFolio, estado: req.estado, motivo: req.motivo, notasRecepcion: req.notasRecepcion, renglones: (ren && ren.renglones) || [], eventos: eventos || [], puedeEditar: req.estado === REQ_DEVUELTA, puedeCancelar: req.estado === REQ_SOLICITADA || req.estado === REQ_DEVUELTA, puedeCerrar: req.estado === REQ_PAGADA, }; } -/** Proyectos donde se pueden pedir materiales: Activos con contrato firmado. Solo folio y nombre (sin cliente ni montos). */ +/** + * Proyectos donde se pueden pedir materiales: Activos y Cerrados, con o sin contrato (los Cancelados no). Solo folio, clave + * y nombre (sin cliente ni montos); se busca por folio, clave y nombre. + */ function listarProyectosRequisicion(busqueda) { exigirRequisicion_(); - const firmados = contratosFirmados_(); const q = textoNormal(busqueda); - const proyectos = nombresDeProyectos_(); - return Object.keys(proyectos).map(f => proyectos[f]).reverse() - .filter(p => p.estado === 'Activo' && firmados[p.folio.toUpperCase()]) - .filter(p => !q || textoNormal(p.folio + ' ' + p.nombre).includes(q)) + return proyectosCache_().slice().reverse() + .filter(p => p.estado !== 'Cancelado') + .filter(p => !q || textoNormal([p.folioVisible, p.clave, p.nombre].join(' ')).includes(q)) .slice(0, 50) - .map(p => ({ folio: p.folio, nombre: p.nombre })); + .map(p => ({ folio: p.folio, clave: p.clave, folioVisible: p.folioVisible, nombre: p.nombre, estado: p.estado })); } -/** Requisiciones del usuario del token (nunca las de otros), más recientes primero. */ -function misRequisiciones() { +/** Requisiciones del usuario del token (nunca las de otros), más recientes primero. En caché por usuario y versiones. */ +function misRequisiciones(soloSiHay) { exigirRequisicion_(); - const libro = libroCompras_(); const yo = String(VENDEDOR_.email).toLowerCase(); + return enCache_('misReq_' + yo, ['requisiciones', 'oc', 'pagosProveedores', 'proyectos'], TTL_CACHE, + soloSiHay ? null : () => misRequisicionesDeHojas_(yo)) || null; +} + +function misRequisicionesDeHojas_(yo) { + const libro = libroComprasSiExiste_(); // solo lee: el libro lo crea la primera requisición + if (!libro) return []; const ren = renglonesDeReqs_(libro), ev = eventosDeReqs_(libro), proyectos = nombresDeProyectos_(); const lista = []; filasDeArea_(hojaReq_(libro), HOJAS_COMPRAS, 'Requisiciones').forEach((r, i) => { @@ -2139,9 +2509,7 @@ function guardarRequisicion(p) { ' renglón(es)' + (bytes ? ' · cotización nueva' : ''), '', HOJAS_COMPRAS); } else { const proyecto = proyectoParaCompras_(d0.proyectoFolio); - if (proyecto.estado !== 'Activo' || !contratosFirmados_()[proyecto.folio.toUpperCase()]) { - throw new Error('Solo se piden materiales para proyectos activos con contrato firmado.'); - } + if (proyecto.estado === 'Cancelado') throw new Error('El proyecto ' + proyecto.folio + ' está cancelado: no se piden materiales.'); const hoja = hojaReq_(libro); folio = siguienteFolio_(PREFIJO_REQ, hoja, 'CONSECUTIVO_REQ_'); version = 1; @@ -2217,9 +2585,16 @@ function notaOpcional_(notas) { // ---- Requisiciones: lado de compras ---- /** Todas las requisiciones (compras), más recientes primero. Filtro opcional por estado y por folio/proyecto/solicitante. */ -function listarRequisiciones(busqueda, estado) { +function listarRequisiciones(busqueda, estado, soloSiHay) { exigirCompras_(); - const libro = libroCompras_(); + const clave = 'reqs_' + String(estado || '') + '_' + textoNormal(busqueda); + return enCache_(clave, ['requisiciones', 'oc', 'pagosProveedores', 'proyectos'], TTL_CACHE, + soloSiHay ? null : () => listarRequisicionesDeHojas_(busqueda, estado)) || null; +} + +function listarRequisicionesDeHojas_(busqueda, estado) { + const libro = libroComprasSiExiste_(); + if (!libro) return []; const ren = renglonesDeReqs_(libro), proyectos = nombresDeProyectos_(); const q = textoNormal(busqueda), e = String(estado || '').trim(); const lista = []; @@ -2228,10 +2603,11 @@ function listarRequisiciones(busqueda, estado) { if (!req.folio) return; const p = proyectos[req.proyectoFolio.toUpperCase()]; const x = { folio: req.folio, fecha: req.fecha, proyectoFolio: req.proyectoFolio, proyectoNombre: p ? p.nombre : '', + proyectoVisible: p ? p.folioVisible : req.proyectoFolio, solicitante: req.solicitante, solicitanteEmail: req.solicitanteEmail, estado: req.estado, ocFolio: req.ocFolio, actualizado: req.actualizado, renglones: ((ren[req.folio.toUpperCase()] || {}).renglones || []).length }; if (e && x.estado !== e) return; - if (q && !textoNormal([x.folio, x.proyectoFolio, x.proyectoNombre, x.solicitante, x.solicitanteEmail, x.ocFolio].join(' ')).includes(q)) return; + if (q && !textoNormal([x.folio, x.proyectoVisible, x.proyectoNombre, x.solicitante, x.solicitanteEmail, x.ocFolio].join(' ')).includes(q)) return; lista.push(x); }); return lista.reverse().slice(0, 200); @@ -2264,23 +2640,23 @@ function vistaRequisicion_(libro, folioReq) { const { req } = buscarReq_(libro, folioReq); const f = req.folio.toUpperCase(); const proyecto = buscarProyecto_(hojaProyectos_(), req.proyectoFolio); - const c = contratosFirmados_()[req.proyectoFolio.toUpperCase()] || null; + const c = monedaDeProyecto_(contratosDelLibro_()[req.proyectoFolio.toUpperCase()] || null); let orden = null; if (req.ocFolio) { orden = buscarOrden_(libro, req.ocFolio).orden; delete orden.fila; } const pagos = req.ocFolio ? pagosDeOrden_(libro, req.ocFolio).map(x => { delete x.fila; return x; }) : []; - const proyectoActivo = !!proyecto && proyecto.estado === 'Activo' && !!c; + const proyectoVigente = !!proyecto && proyecto.estado !== 'Cancelado'; // Activo o Cerrado, con o sin contrato const r = Object.assign({}, req); delete r.fila; return { requisicion: r, renglones: (renglonesDeReqs_(libro)[f] || {}).renglones || [], eventos: eventosDeReqs_(libro)[f] || [], - proyecto: proyecto ? { folio: proyecto.folio, nombre: proyecto.nombre, cliente: proyecto.cliente, estado: proyecto.estado, - ubicacion: proyecto.ubicacion } : { folio: req.proyectoFolio, nombre: '', cliente: '', estado: '', ubicacion: '' }, - moneda: c ? c.moneda : '', marca: c ? c.marca : '', + proyecto: proyecto ? datosProyectoCompras_(proyecto) + : { folio: req.proyectoFolio, clave: '', folioVisible: req.proyectoFolio, nombre: '', cliente: '', estado: '', ubicacion: '' }, + moneda: c.moneda, marca: c.marca, sinContrato: c.sinContrato, orden: orden, pagos: pagos, proveedores: proveedoresDelLibro_(libro).map(p => { const x = Object.assign({}, p); delete x.fila; return x; }), ivaTasas: IVA_OC, metodos: METODOS_PAGO, hoy: hoyIso_(), - puedeAutorizar: req.estado === REQ_SOLICITADA && proyectoActivo, + puedeAutorizar: req.estado === REQ_SOLICITADA && proyectoVigente, puedeRevisar: req.estado === REQ_SOLICITADA, puedePagar: req.estado === REQ_AUTORIZADA && !!orden && orden.estado === OC_EMITIDA, puedeCancelarPago: req.estado === REQ_PAGADA, @@ -2600,7 +2976,8 @@ function idCliente(c) { return 'SIN-RFC:' + textoNormal(c && c.nombre) + '|' + textoNormal(c && c.empresa); } -/** Valida y limpia un proyecto nuevo. El cliente se guarda como "nombre — empresa" más su cliente_id. */ +/** Valida y limpia un proyecto nuevo. El cliente se guarda como "nombre — empresa" más su cliente_id; la clave (3 letras) + * es la capturada o, si viene vacía, la sugerida con el nombre. */ function normalizarProyecto(p) { const nombre = String((p && p.nombre) || '').trim().replace(/\s+/g, ' '); const ubicacion = String((p && p.ubicacion) || '').trim(); @@ -2615,6 +2992,7 @@ function normalizarProyecto(p) { nombre: nombre, ubicacion: ubicacion, cliente: cli.empresa && textoNormal(cli.empresa) !== textoNormal(cli.nombre) ? cli.nombre + ' — ' + cli.empresa : cli.nombre, clienteId: idCliente(cli), + clave: validarClaveProyecto(p && p.clave, nombre), }; } @@ -2627,27 +3005,73 @@ function nombreCarpetaProyecto(folio, cliente, nombre) { /** Filas de la pestaña Proyectos (sin encabezado) → objetos. Columnas en ENCABEZADOS_PROYECTOS. */ function proyectosDesdeFilas(filas) { - return (filas || []).filter(r => String(r[0] || '').trim()).map(r => ({ - folio: String(r[0]).trim(), - fecha: r[1] instanceof Date - ? [r[1].getDate(), r[1].getMonth() + 1].map(x => String(x).padStart(2, '0')).join('/') + '/' + r[1].getFullYear() - : String(r[1] || ''), - nombre: String(r[2] || ''), cliente: String(r[3] || ''), clienteId: String(r[4] || ''), - ubicacion: String(r[5] || ''), - estado: String(r[6] || '').trim() || 'Activo', - carpetaId: String(r[7] || ''), - })); + return (filas || []).filter(r => String(r[0] || '').trim()).map(r => { + const folio = String(r[0]).trim(), clave = String(r[11] || '').trim().toUpperCase(); + return { + folio: folio, + fecha: r[1] instanceof Date + ? [r[1].getDate(), r[1].getMonth() + 1].map(x => String(x).padStart(2, '0')).join('/') + '/' + r[1].getFullYear() + : String(r[1] || ''), + nombre: String(r[2] || ''), cliente: String(r[3] || ''), clienteId: String(r[4] || ''), + ubicacion: String(r[5] || ''), + estado: String(r[6] || '').trim() || 'Activo', + carpetaId: String(r[7] || ''), + clave: clave, folioVisible: folioVisible(folio, clave), + }; + }); } -/** Busca por folio, cliente o nombre (sin acentos ni mayúsculas); más recientes primero; máximo n. */ +/** Busca por folio (con o sin clave), clave, cliente o nombre (sin acentos ni mayúsculas); más recientes primero; máximo n. */ function filtrarProyectos(lista, busqueda, incluirInactivos, n) { const q = textoNormal(busqueda); return (lista || []).slice().reverse() .filter(p => incluirInactivos || p.estado === 'Activo') - .filter(p => !q || textoNormal(p.folio + ' ' + p.cliente + ' ' + p.nombre).includes(q)) + .filter(p => !q || textoNormal([p.folioVisible || p.folio, p.clave, p.cliente, p.nombre].join(' ')).includes(q)) .slice(0, n || 50); } +// ---- Clave de proyecto: folio visible PRY-AAAA-NNNN-XXX ---- +// La llave interna sigue siendo PRY-AAAA-NNNN (proyecto_folio en Cotizaciones, Cobranza y Compras); la clave va aparte +// (columna L de Proyectos), se sugiere con el nombre, se puede editar al crear y después no cambia. + +const PALABRAS_SIN_CLAVE = ['DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'Y', 'EN', 'PARA']; + +/** + * Clave sugerida con el nombre: mayúsculas sin acentos (Ñ → N), sin números ni símbolos y sin DE, DEL, LA, LAS, EL, LOS, Y, + * EN, PARA. 1 palabra = sus 3 primeras letras; 2 = 2 letras de la primera + inicial de la segunda; 3 o más = iniciales de + * las tres primeras. Si faltan letras se completa con X. "DROGHEDA" → DRO; "Casa del Lago" → CAL; "Planta de Agua Norte" → PAN. + */ +function claveSugerida(nombre) { + const palabras = String(nombre || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase() + .replace(/[^A-Z]+/g, ' ').trim().split(' ').filter(p => p && PALABRAS_SIN_CLAVE.indexOf(p) === -1); + let c = ''; + if (palabras.length === 1) c = palabras[0].slice(0, 3); + else if (palabras.length === 2) c = palabras[0].slice(0, 2) + palabras[1].charAt(0); + else if (palabras.length > 2) c = palabras.slice(0, 3).map(p => p.charAt(0)).join(''); + return (c + 'XXX').slice(0, 3); +} + +/** Clave capturada: exactamente 3 letras A–Z (se aceptan minúsculas); vacía = la sugerida con el nombre. */ +function validarClaveProyecto(clave, nombre) { + const c = String(clave == null ? '' : clave).trim().toUpperCase(); + if (!c) return claveSugerida(nombre); + if (!/^[A-Z]{3}$/.test(c)) throw new Error('La clave del proyecto debe tener exactamente 3 letras (A–Z, sin acentos ni Ñ).'); + return c; +} + +/** "PRY-2026-0002" + "DRO" → "PRY-2026-0002-DRO"; sin clave (proyectos sin asignar), el folio solo. */ +function folioVisible(folio, clave) { + const f = String(folio || '').trim().toUpperCase(), c = String(clave || '').trim().toUpperCase(); + return c ? f + '-' + c : f; +} + +/** Llave interna a partir de lo que llegue: "PRY-2026-0002-DRO", "pry-2026-0002" o "PRY-2026-0002" → "PRY-2026-0002". */ +function folioInterno(texto) { + const s = String(texto || '').trim().toUpperCase(); + const m = s.match(/^(PRY-\d{4}-\d{4})(-[A-Z]{3})?$/); + return m ? m[1] : s; +} + // ---- Monedas (MXN / USD) ---- // Cada cotización lleva su moneda (columna U); nunca se suman importes de monedas distintas. @@ -3085,7 +3509,8 @@ function resumenGeneralCobranza(d) { margenError = e.message; } renglones.push({ - folio: p.folio, nombre: p.nombre, cliente: p.cliente, estadoProyecto: p.estado, marca: c.marca, moneda: c.moneda, + folio: p.folio, folioVisible: p.folioVisible || p.folio, nombre: p.nombre, cliente: p.cliente, estadoProyecto: p.estado, + marca: c.marca, moneda: c.moneda, estadoContrato: c.estado === ESTADO_FIRMADO ? 'Firmado' : 'Por firmar', cotizacion: c.cotizacion, fechaFirma: c.fechaFirma, contratado: r.contratado, subtotal: r.subtotal, pagado: r.pagado, saldo: r.saldo, avance: r.avance, siguienteEtapa: siguiente ? siguiente.etapa : '', siguienteSaldo: siguiente ? siguiente.saldo : 0, ultimoPago: ultimoPago, diff --git a/tests/catalogo.test.js b/tests/catalogo.test.js index 5e6bd97..702a229 100644 --- a/tests/catalogo.test.js +++ b/tests/catalogo.test.js @@ -23,7 +23,7 @@ let borradas=[],creados=0,catSS; const db={getId:()=>'db',getSheetByName:n=>dbSheets[n]||null,getSheets:()=>Object.values(dbSheets),deleteSheet:h=>{borradas.push(h.getName());delete dbSheets[h.getName()]}}; const props={SPREADSHEET_ID:'db',FOLDER_ID:'f'}; const ctx={console,Date, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, SpreadsheetApp:{flush(){},newDataValidation:()=>chain, diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index 691e54b..dca1931 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -72,14 +72,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const ctx={console:{...console,warn(){}},Date,JSON, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>''})}, Logger:{log(){}}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), - DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, + DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, @@ -165,7 +165,7 @@ const sinProy=cot('',50); // --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO --- const dAna=ok('ana','getDatosIniciales'); assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false}); -assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza'); +{ const {versiones, ...resto} = dAna; assert(!/contrato|pagos|saldo/i.test(JSON.stringify(resto)),'datos iniciales sin cobranza'); } // versiones solo trae nombres de colecciones y números const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]], ['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]]; for(const [f,args] of COB) falla('ana',f,args,/No tienes permiso de cobranza/,'PERMISO'); @@ -371,7 +371,9 @@ assert.deepEqual([tMXN.proyectos,tMXN.conSaldo,tMXN.contratado,tMXN.cobrado,tMXN assert.deepEqual([tUSD.proyectos,tUSD.conSaldo,tUSD.contratado,tUSD.cobrado,tUSD.porCobrar,tUSD.avance],[1,0,1000,1000,0,100],'USD aparte: nunca se suma con MXN'); assert.deepEqual(rs.totales.map(t=>t.moneda),['MXN','USD']); // Proyecto cancelado: no aparece ni suma -db.pest.Proyectos.m[filaP3][6]='Cancelado'; +db.pest.Proyectos.m[filaP3][6]='Cancelado'; // editado a mano en la hoja (la app no sube la versión) +// A los 5 min vence el caché de Proyectos: al releerla, la huella cambia, sube la versión y el resumen se recalcula +for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k]; rs=ok('tania','resumenCobranzaGeneral'); assert(!rs.proyectos.some(x=>x.folio===p3)); assert.equal(rs.totales.find(t=>t.moneda==='MXN').porFirmar,0); db.pest.Proyectos.m[filaP3][6]='Activo'; diff --git a/tests/compras.test.js b/tests/compras.test.js index d9bd078..2052106 100644 --- a/tests/compras.test.js +++ b/tests/compras.test.js @@ -74,14 +74,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const ctx={console:{...console,warn(){}},Date,JSON, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>''})}, Logger:{log(){}}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), - DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, + DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, @@ -190,12 +190,14 @@ const PDF=Buffer.from('%PDF-1.4 cotizacion del proveedor').toString('base64'); const req=(proyectoFolio)=>ok('obrero','guardarRequisicion',{proyectoFolio,renglones:[{descripcion:'Material',cantidad:1,unidad:'pza'}], cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'rq'+(++nId)}).folio; const reqP4=req(p4); -const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado'; +const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado'; // a mano en la hoja +for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k]; // pasan los 5 min del caché -// --- Lista: solo Activos con contrato firmado, con su moneda y sin montos --- +// --- Lista: Activos y Cerrados con o sin contrato (los Cancelados no), con su moneda y sin montos --- const lista=ok('pedro','listarProyectosCompras',''); -assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca),[p2+':USD:energy',p1+':MXN:helgen']); -assert(!/total|subtotal|contrat/i.test(JSON.stringify(lista)),'sin montos del contrato'); +assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca+':'+p.sinContrato), + [p3+':MXN:helgen:false',p2+':USD:energy:false',p1+':MXN:helgen:false'],'p3 tiene aceptada sin firmar; p4 está cancelado'); +assert(!/total|subtotal|contratado|margen/i.test(JSON.stringify(lista)),'sin montos del contrato'); assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]); // --- Vista: sin montos del contrato ni margen; el libro y la carpeta son privados --- @@ -235,7 +237,7 @@ assert(!/4,100|4100/.test(html),'sin precio de venta'); assert(html.includes('DO // Idempotencia assert.equal(ok('pedro','autorizarRequisicion',REQ('0002'),oc({idSolicitud:'oc1'})).ocFolio,r.folio); assert.equal(H('OrdenesCompra').length,2); // Rechazos que no gastan folio (la requisición sigue Solicitada) -falla('obrero','guardarRequisicion',[{proyectoFolio:p3,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/); +falla('obrero','guardarRequisicion',[{proyectoFolio:p4,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/está cancelado: no se piden materiales/); falla('pedro','autorizarRequisicion',[reqP4,oc()],/está cancelado: no admite órdenes/); const rPend=req(p1); falla('pedro','autorizarRequisicion',[rPend,oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/); @@ -306,12 +308,10 @@ falla('pedro','resumenCobranzaGeneral',[],/No tienes permiso de cobranza/,'PERMI // --- Con órdenes vigentes en MXN, la aceptada no puede cambiar a USD (tras desbloquear) --- ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes'); falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/); -// Sin contrato firmado: sale de la lista y no se emiten órdenes; las que tiene se siguen viendo -assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio),[p2]); +// Sin contrato firmado (desbloqueado) sigue en la lista y admite requisiciones y órdenes en la moneda de su aceptada +assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio+':'+p.moneda),[p3+':MXN',p2+':USD',p1+':MXN']); v=ok('pedro','getComprasProyecto',p1); assert.equal(v.ordenes.length,3); -assert.equal(ok('pedro','getRequisicion',rPend).puedeAutorizar,false); -falla('pedro','autorizarRequisicion',[rPend,oc()],/contrato firmado/); -falla('obrero','guardarRequisicion',[{proyectoFolio:p1,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/); +assert.equal(ok('pedro','getRequisicion',rPend).puedeAutorizar,true); ok('efrain','firmarContrato',p1,HOY_ISO); // --- Bitácora, separación de datos y reparación del acceso --- @@ -327,4 +327,30 @@ assert.deepEqual(Object.keys(libros[props.COBRANZA_DB_ID].pest).sort(),['Bitacor assert(vivos(maestra).every(x=>!/Compras/.test(x.nombre)),'nada de compras en la carpeta maestra'); carpCom.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCompras(); assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCompras repara el acceso'); + +// --- Proyecto sin cotización aceptada: requisición, orden (MXN, sin línea de negocio) y pago; Cobranza ve "Sin contrato" --- +const p5=proy('Bodega Norte','p5','Saltillo'); +const cerrado=proy('Ya terminado','p6',''); +db.pest.Proyectos.m[db.pest.Proyectos.m.findIndex(r=>r[0]===cerrado)][6]='Cerrado'; +for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k]; +const rl=ok('obrero','listarProyectosRequisicion',''); +assert(rl.some(p=>p.folio===p5)&&rl.some(p=>p.folio===cerrado)&&!rl.some(p=>p.folio===p4),'Activos y Cerrados sin contrato; Cancelados no'); +assert.equal(ok('obrero','listarProyectosRequisicion','BON').map(p=>p.folio)[0],p5,'se busca por clave'); +const r5=req(p5); +let v5=ok('pedro','getRequisicion',r5); +assert.deepEqual([v5.moneda,v5.marca,v5.sinContrato,v5.puedeAutorizar],['MXN','',true,true]); +const o5=ok('pedro','autorizarRequisicion',r5,oc({idSolicitud:'o5',proyectoFolio:p5,renglones:[{descripcion:'Cemento',cantidad:4,costo:250}]})); +const fo5=H('OrdenesCompra').find(x=>x[0]===o5.ocFolio); +assert.deepEqual([fo5[2],fo5[3],fo5[13],fo5[18],fo5[20]],[p5,'','MXN','MXN',1000],'sin contrato: MXN y sin línea de negocio'); +ok('pedro','registrarPagoProveedor',{reqFolio:r5,fecha:HOY_ISO,monto:1160,monedaPago:'MXN',metodo:'Transferencia',referencia:'SPEI 5', + comprobante:{nombre:'p.pdf',datos:PDF},idSolicitud:'pg5'}); +assert.equal(ok('pedro','getRequisicion',r5).requisicion.estado,'Pagada'); +const vc5=ok('efrain','getCobranzaProyecto',p5); +assert.deepEqual([vc5.contrato,vc5.margen.sinContrato,vc5.margen.costos[0].moneda,vc5.margen.costos[0].monto,vc5.margen.costos[0].cantidad], + [null,true,'MXN',1000,1],'Cobranza: costo de las órdenes y "Sin contrato"'); +falla('efrain','registrarPago',[{proyectoFolio:p5,fecha:HOY_ISO,monto:100,monedaPago:'MXN',metodo:'Efectivo',comprobante:{nombre:'p.pdf',datos:PDF}}], + /contrato firmado/); +// Con órdenes en MXN no se puede aceptar una cotización en USD (nunca se mezclan monedas) +const c5usd=cot(p5,100,{moneda:'USD'}); +falla('efrain','marcarAceptada',[p5,c5usd],/órdenes de compra en MXN/); console.log('OK compras'); diff --git a/tests/cotizacion.test.js b/tests/cotizacion.test.js index 9dac5c9..5231fec 100644 --- a/tests/cotizacion.test.js +++ b/tests/cotizacion.test.js @@ -14,11 +14,11 @@ const cfg=hoja([['Clave','Valor'],['EMPRESA_NOMBRE','Helgen Technologies'],['EMP const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas']]); const props={SPREADSHEET_ID:'x',FOLDER_ID:'y'},cache={},pdfs=[]; const ctx={console,Date, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, - Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, + Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, SpreadsheetApp:{flush(){},openById:()=>({getSheetByName:n=>n==='Config'?cfg:cot})}, DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:b=>{pdfs.push(b);return{getUrl:()=>'u'}}})})})}, }; diff --git a/tests/interfaz.test.js b/tests/interfaz.test.js index 3c6cf0e..5b63449 100644 --- a/tests/interfaz.test.js +++ b/tests/interfaz.test.js @@ -19,25 +19,33 @@ const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio: const TC={valor:18.45,fuente:'Google Finance'}; /** App cargada con un DOM y un servidor simulados nuevos. */ -function montar(datosIniciales){ +function montar(datosIniciales,opts){ + opts=opts||{}; function elemento(tag){const e={tag,value:'',textContent:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{}, _clases:new Set(),porClase:{}, classList:{add:c=>e._clases.add(c),remove:c=>e._clases.delete(c),contains:c=>e._clases.has(c),toggle:(c,f)=>f?e._clases.add(c):e._clases.delete(c)}, addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f), disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})), - focus(){},remove(){},appendChild:h=>e.hijos.push(h), + focus(){},remove(){},appendChild:h=>e.hijos.push(h),prepend:h=>e.hijos.unshift(h),contains:()=>false, querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')), querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]}; // Como el DOM: innerHTML = '' quita los renglones agregados con appendChild let h='';Object.defineProperty(e,'innerHTML',{get:()=>h,set:v=>{h=v;if(v==='')e.hijos.length=0}});return e} const porId={}; + const oyentesDoc={}; const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')), - querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)}; + querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t),visibilityState:'visible',activeElement:null, + addEventListener:(t,f)=>(oyentesDoc[t]=oyentesDoc[t]||[]).push(f),dispatchEvent:ev=>(oyentesDoc[ev.type]||[]).forEach(f=>f(ev))}; document.getElementById('login').classList.add('oculto'); document.getElementById('tabCob').classList.add('oculto'); // como en el HTML document.getElementById('tabCom').classList.add('oculto'); const almacen={cotizadorHelgenSesion:token}; - const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}}; + // bootstrap = la carga inicial; sincronizar sin cambios (mismos permisos) salvo que la prueba ponga otra respuesta + const llamadas=[], respuestas={bootstrap:{ok:true,datos:datosIniciales}, + sincronizar:()=>({ok:true,datos:{versiones:{},permisos:datosIniciales.permisos,soloRequisicion:!!datosIniciales.soloRequisicion,cambios:[]}})}; + const local={...(opts.local||{})}, intervalos=[]; + // bootstrapPendiente: la respuesta llega cuando la prueba llama la función que recibe (para ver la página antes) + if (opts.bootstrapPendiente) respuestas.bootstrap=()=>new Promise(res=>opts.bootstrapPendiente(()=>res({ok:true,datos:datosIniciales}))); async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c); const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r; return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}} @@ -47,11 +55,13 @@ function montar(datosIniciales){ atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()}, sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]}, confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt}, - setTimeout,clearTimeout,scrollTo(){}}; + localStorage:{getItem:k=>local[k]??null,setItem:(k,v)=>local[k]=String(v),removeItem:k=>delete local[k]}, + CustomEvent:class{constructor(type,o){this.type=type;this.detail=o&&o.detail}}, + setTimeout,clearTimeout,setInterval:(f,ms)=>intervalos.push(f),scrollTo(){}}; ctx.window=ctx; vm.createContext(ctx); vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx); - return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,run:c=>vm.runInContext(c,ctx)}; + return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,local,intervalos,document,run:c=>vm.runInContext(c,ctx)}; } const esperar=async(n=10)=>{for(let i=0;isetImmediate(r))}; @@ -188,7 +198,18 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia const nRes=B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length; B.run('verResumenCob()'); await esperar(); assert(!B.$('cobVistaResumen').classList.contains('oculto')&&B.$('cobVistaProyecto').classList.contains('oculto')); - assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes+1,'al regresar se actualiza el resumen'); + assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes,'al regresar no se vuelve a pedir: lo mantiene al día la sincronización'); + // Sincronización: manda el contexto (resumen y proyecto abiertos) y pinta lo que cambió sin recargar + B.respuestas.sincronizar={ok:true,datos:{versiones:{pagosCobranza:7},permisos:{cobranza:true,desbloqueo:false},cambios:['pagosCobranza'], + resumen:{...RES,proyectos:RES.proyectos.slice(0,1)}}}; + B.run('sincronizarAhora()'); await esperar(); + const sy=B.llamadas.filter(c=>c.funcion==='sincronizar').pop(); + assert.equal(sy.args[1].cobResumen,true); assert.equal(sy.args[1].cobProyecto,'PRY-2026-0001'); + assert.deepEqual(B.$('resLista').innerHTML.match(/data-folio="([^"]+)"/g),['data-folio="PRY-2026-0003"'],'el resumen se actualizó solo'); + assert.equal(B.run('VERS.pagosCobranza'),7,'guarda las versiones nuevas'); + B.respuestas.sincronizar=()=>({ok:true,datos:{versiones:{pagosCobranza:7},permisos:{cobranza:true,desbloqueo:false},cambios:[]}}); + B.run("RESUMEN=null"); B.run('verResumenCob()'); await esperar(); + assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes+1,'sin resumen cargado sí se pide'); assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML)); const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'}, {folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}]; @@ -469,5 +490,67 @@ const esperar=async(n=10)=>{for(let i=0;isetImmedia assert(!S.$('tabReq').classList.contains('oculto')&&!S.$('vistaReq').classList.contains('oculto')&&S.$('vistaNueva').classList.contains('oculto')); assert(S.llamadas.some(c=>c.funcion==='misRequisiciones')&&/Todavía no tienes requisiciones/.test(S.$('reqLista').innerHTML)); assert(!S.llamadas.some(c=>['listarCotizaciones','getCatalogo','listarProyectos'].includes(c.funcion)),'no llama nada más'); + + // ===== Carga rápida: pestañas al instante con los roles de la última sesión (localStorage); el bootstrap las corrige ===== + let liberar; + const PRYC={...PRY,clave:'TEC',folioVisible:'PRY-2026-0001-TEC'}; + const T=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[PRYC],marcas:{},versiones:{proyectos:1}, + permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:false}, + com:{proyectos:[{...PRYC,moneda:'MXN',marca:'',sinContrato:true}],requisiciones:[]}}, + {local:{'cotizadorHelgenRoles:ana@helgen.tech':JSON.stringify({permisos:{cobranza:true,compras:false,requisicion:true},soloRequisicion:false})}, + bootstrapPendiente:f=>liberar=f}); + await new Promise(r=>setTimeout(r,5)); // los roles guardados se aplican con setTimeout al terminar de cargar el script + assert(!T.$('tabCob').classList.contains('oculto')&&!T.$('tabReq').classList.contains('oculto')&&T.$('tabCom').classList.contains('oculto'), + 'antes de responder el servidor: las pestañas de la última sesión'); + liberar(); await esperar(10); + assert(T.$('tabCob').classList.contains('oculto')&&T.$('tabReq').classList.contains('oculto')&&!T.$('tabCom').classList.contains('oculto'),'el bootstrap corrige'); + assert.deepEqual(JSON.parse(T.local['cotizadorHelgenRoles:ana@helgen.tech']).permisos,{cobranza:false,desbloqueo:false,compras:true,requisicion:false}); + assert.deepEqual(T.llamadas.map(c=>c.funcion),['bootstrap'],'al entrar: una sola llamada'); + assert(/PRY-2026-0001-TEC — Luis — Solar MX — Techo/.test(T.$('proyectosList').innerHTML),'folio con clave al cotizar'); + assert(/PRY-2026-0001-TEC — Luis — Solar MX — Techo/.test(T.$('comProyectosList').innerHTML)&&/MXN · sin contrato/.test(T.$('comProyectosList').innerHTML)); + T.$('proyecto').value='PRY-2026-0001-TEC — Luis — Solar MX — Techo'; T.$('proyecto').disparar('change'); + assert.equal(T.run('proyectoElegido().folio'),'PRY-2026-0001','se elige por la llave interna'); + assert.equal(T.intervalos.length,1,'consulta cada 20 s'); + // Cambio de rol sin recargar: aparece la pestaña Requisiciones (proyectos activos y cerrados) + T.respuestas.sincronizar={ok:true,datos:{versiones:{proyectos:2},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:['proyectos'], + reqProyectos:[{folio:'PRY-2026-0002',clave:'DRO',folioVisible:'PRY-2026-0002-DRO',nombre:'DROGHEDA',estado:'Cerrado'}]}}; + T.respuestas.misRequisiciones={ok:true,datos:[]}; T.respuestas.listarProyectosRequisicion={ok:true,datos:[]}; + T.intervalos[0](); await esperar(10); + assert(!T.$('tabReq').classList.contains('oculto'),'pestaña nueva sin recargar'); + assert(/PRY-2026-0002-DRO — DROGHEDA" label="Cerrado"/.test(T.$('reqProyectosList').innerHTML),T.$('reqProyectosList').innerHTML); + assert.equal(T.run('VERS.proyectos'),2); + assert.deepEqual(T.llamadas.filter(c=>c.funcion==='sincronizar').pop().args[0],{proyectos:1},'manda las versiones que tiene'); + // Con la pestaña del navegador oculta no consulta; al volver, sí + const nSync=()=>T.llamadas.filter(c=>c.funcion==='sincronizar').length, n0=nSync(); + T.document.visibilityState='hidden'; T.intervalos[0](); await esperar(); + assert.equal(nSync(),n0,'pestaña oculta: no consulta'); + T.document.visibilityState='visible'; T.respuestas.sincronizar=()=>({ok:true,datos:{versiones:{proyectos:2},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:[]}}); + T.document.dispatchEvent({type:'visibilitychange'}); await esperar(); + assert.equal(nSync(),n0+1,'al volver a la pestaña consulta'); + T.$('comProyecto').disparar('focus'); await esperar(); + assert.equal(nSync(),n0+2,'al abrir un buscador de proyectos consulta'); + // Alguien está capturando en la vista abierta: no se repinta sola; se avisa y se actualiza con un clic + const vistaCob={proyecto:PRYC,cotizaciones:[],contrato:null,esquema:[],resumen:null,pagos:[],puedeAceptar:true,puedeFirmar:false,metodos:[],hoy:'2026-10-20', + margen:{sinContrato:true,costos:[{concepto:'Órdenes de compra',moneda:'MXN',monto:1000,cantidad:1}]}}; + T.run("COB={proyecto:{folio:'PRY-2026-0001'}}"); T.$('cobVistaProyecto').disparar('input'); + T.respuestas.sincronizar={ok:true,datos:{versiones:{proyectos:2,oc:3},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:['oc'],cobranzaProyecto:vistaCob}}; + T.run('sincronizarAhora()'); await esperar(); + assert.equal(T.run('COB.proyecto.clave'),undefined,'no se repintó mientras capturaba'); + const avisoSync=T.$('cobVistaProyecto').querySelector('.aviso-sync'); + assert(/Otra persona cambió esta información/.test(avisoSync.innerHTML)); + avisoSync.querySelector('a').onclick({preventDefault(){}}); + assert(/PRY-2026-0001-TEC/.test(T.$('cobInfo').innerHTML),'con el clic se actualiza'); + assert(/Costo de órdenes de compra/.test(T.$('cobMargen').innerHTML)&&/\$1,000\.00 MXN/.test(T.$('cobMargen').innerHTML)&&/Sin contrato/.test(T.$('cobMargen').innerHTML), + 'sin contrato: costo de las órdenes y "Sin contrato"'); + // Clave del proyecto: se sugiere con el nombre y se puede editar (3 letras A–Z) + T.$('npNombre').value='Casa del Lago'; T.$('npNombre').disparar('input'); + assert.equal(T.$('npClave').value,'CAL'); assert(/PRY-\d{4}-NNNN-CAL/.test(T.$('npFolio').textContent)); + T.$('npClave').value='xy1z'; T.$('npClave').disparar('input'); assert.equal(T.$('npClave').value,'XYZ'); + T.$('npNombre').value='Otra cosa'; T.$('npNombre').disparar('input'); assert.equal(T.$('npClave').value,'XYZ','la editada a mano no se reemplaza'); + assert.equal(T.run("claveSugerida('DROGHEDA')+claveSugerida('Planta de Agua Norte')+claveSugerida('Ñandú')"),'DROPANNAN'); + T.respuestas.crearProyecto={ok:true,datos:{folio:'PRY-2026-0009',clave:'XYZ',folioVisible:'PRY-2026-0009-XYZ',nombre:'Otra cosa',cliente:'Luis',estado:'Activo',url:'u'}}; + T.$('cNombre').value='Luis'; T.run('crearProyecto()'); await esperar(); + assert.equal(T.llamadas.find(c=>c.funcion==='crearProyecto').args[0].clave,'XYZ'); + assert(/PRY-2026-0009-XYZ/.test(T.$('aviso').innerHTML)&&/^PRY-2026-0009-XYZ — /.test(T.$('proyecto').value)); console.log('OK interfaz'); })().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/proyectos.test.js b/tests/proyectos.test.js index ea6f7f8..b4c2327 100644 --- a/tests/proyectos.test.js +++ b/tests/proyectos.test.js @@ -12,7 +12,7 @@ function hoja(m){return{m,getLastRow:()=>m.length,getMaxRows:()=>1000,setFrozenR getValues:()=>Array.from({length:nr},(_,i)=>{const f=(m[r-1+i]||[]).slice(c-1,c-1+nc);while(f.length{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this}, - setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})}} + setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this},setNumberFormat(){return this}})}} // Drive simulado: árbol de carpetas con Papelera let nId=0; const carpetas={}; @@ -37,11 +37,11 @@ const db={getSheetByName:n=>n==='Config'?cfg:n==='Cotizaciones'?cot:n==='Proyect const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT'},cache={}; const dv=()=>{const b={requireValueInList:(l)=>{b.lista=l;return b},setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}; const ctx={console:{...console,warn(){}},Date,JSON, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>''})}, - Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'20/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, + Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'20/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, SpreadsheetApp:{flush(){},newDataValidation:dv, openById:id=>id==='CAT'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:db}, DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]}, @@ -60,6 +60,14 @@ assert.throws(()=>ctx.normalizarProyecto({nombre:'P',cliente:{}}),/cliente del p assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Luis',empresa:'Solar MX'}}).cliente,'Luis — Solar MX'); assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Solar MX',empresa:'solar mx'}}).cliente,'Solar MX'); assert.equal(ctx.nombreCarpetaProyecto('PRY-2026-0001','A/B: "C"','Techo*1?'),'PRY-2026-0001 — A B C — Techo 1'); +// Clave de proyecto (3 letras): sugerida con el nombre, editable al crear, validada en el servidor +for (const [nombre,clave] of [['DROGHEDA','DRO'],['Casa del Lago','CAL'],['Planta de Agua Norte','PAN'],['Ñandú','NAN'], + ['El Sol','SOL'],['Línea 3','LIN'],['4 Torres de Querétaro','TOQ'],['Planta para la Energía del Sol','PES'],['Y','XXX'],['',"XXX"], + ['Yo','YOX'],['A1 B2','ABX'],['Nave Industrial','NAI'],['Proyecto de Prueba oficial','PPO']]) assert.equal(ctx.claveSugerida(nombre),clave,nombre); +assert.equal(ctx.validarClaveProyecto(' dro ','x'),'DRO'); assert.equal(ctx.validarClaveProyecto('','Casa del Lago'),'CAL','vacía = sugerida'); +for (const mal of ['DR','DROG','DRÓ','DR1','D-O','ÑAN']) assert.throws(()=>ctx.validarClaveProyecto(mal,'x'),/exactamente 3 letras/,mal); +assert.deepEqual([ctx.folioVisible('PRY-2026-0002','dro'),ctx.folioVisible('PRY-2026-0002','')],['PRY-2026-0002-DRO','PRY-2026-0002']); +assert.deepEqual(['PRY-2026-0002-DRO','pry-2026-0002',' PRY-2026-0002 ','',null].map(ctx.folioInterno),['PRY-2026-0002','PRY-2026-0002','PRY-2026-0002','','']); // El cliente_id de clientesDesdeFilas es el mismo que el del proyecto const F=(n,e,rfc)=>{const r=['F','x',n,e,rfc,'','',0,0,0,'','','','','',''];return r}; assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','')])[0].id,'SIN-RFC:luis|solar mx'); @@ -69,14 +77,15 @@ assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','LUSM800101AB1')])[0].i const nuevo=(extra)=>({nombre:'Techo solar planta',ubicacion:'Querétaro',cliente:{nombre:'Luis',empresa:'Solar MX',rfc:'LUSM800101AB1'},...extra}); const p1=ctx.crearProyecto(nuevo({idSolicitud:'p1',vendedor:{email:'impostor@helgen.tech'}})); assert.equal(p1.folio,'PRY-2026-0001'); assert.equal(p1.estado,'Activo'); assert.equal(p1.clienteId,'LUSM800101AB1'); -assert.equal(pry.m[0].join(','),'Folio,Fecha,Nombre,Cliente,cliente_id,Ubicación,Estado,carpeta_id,vendedor_email,vendedor_nombre,vendedor_apellido'); +assert.equal(pry.m[0].join(','),'Folio,Fecha,Nombre,Cliente,cliente_id,Ubicación,Estado,carpeta_id,vendedor_email,vendedor_nombre,vendedor_apellido,clave'); const f1=pry.m[1]; assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']); -assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor'); +assert.deepEqual(f1.slice(8,11),['ana@helgen.tech','Ana','López'],'vendedor del servidor'); +assert.equal(f1[11],'TSP','clave sugerida con el nombre (Techo solar planta) en la columna L'); assert(!JSON.stringify(pry.m).includes('impostor')); // Carpeta: Cotizador Helgen/Proyectos/2026/ — — / solo con Cotizaciones (Pagos y Órdenes de compra ya no: van en cobranza y compras) const c1=carpetas[f1[7]]; -assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta'); +assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001-TSP — Luis — Solar MX — Techo solar planta'); assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones']); assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1'); assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia'); @@ -113,7 +122,7 @@ ctx.guardarCotizacion({...base,idSolicitud:'c2'}); assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],''); // Historial const h=ctx.listarCotizaciones(''); -assert.deepEqual(h.map(c=>c.proyecto),['Sin proyecto','PRY-2026-0001','Sin proyecto']); +assert.deepEqual(h.map(c=>c.proyecto),['Sin proyecto','PRY-2026-0001-TSP','Sin proyecto'],'el Historial muestra el folio con clave'); assert.deepEqual(ctx.listarCotizaciones('pry-2026-0001').map(c=>c.folio),['HLG-2026-0005'],'busca por proyecto'); // Proyecto inexistente o cancelado: error y no se consume folio assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0099',idSolicitud:'c3'}), diff --git a/tests/requisiciones.test.js b/tests/requisiciones.test.js index ee87895..f617b10 100644 --- a/tests/requisiciones.test.js +++ b/tests/requisiciones.test.js @@ -77,14 +77,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const ctx={console:{...console,warn(){}},Date,JSON, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>''})}, Logger:{log(){}}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), - DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, + DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, @@ -185,10 +185,15 @@ for(const [f,args] of [['getClientes',[]],['listarCotizaciones',['']],['getCatal } assert.equal(ok('obrero','listarCotizaciones','').length>0,true,'ROL_REQUISICION sin SOLO usa el cotizador como siempre'); -// --- Proyectos para pedir: Activos con contrato firmado; solo folio y nombre --- +// --- Proyectos para pedir: Activos y Cerrados, con o sin contrato; solo folio, clave y nombre --- const lp=ok('luis','listarProyectosRequisicion',''); -assert.deepEqual(lp.map(p=>({...p})),[{folio:p2,nombre:'Planta Texas'},{folio:p1,nombre:'Techo solar'}]); +assert.deepEqual(lp.map(p=>({...p})),[ + {folio:p3,clave:'SIF',folioVisible:p3+'-SIF',nombre:'Sin firmar',estado:'Activo'}, + {folio:p2,clave:'PLT',folioVisible:p2+'-PLT',nombre:'Planta Texas',estado:'Activo'}, + {folio:p1,clave:'TES',folioVisible:p1+'-TES',nombre:'Techo solar',estado:'Activo'}]); assert.deepEqual(ok('luis','listarProyectosRequisicion','texas').map(p=>p.folio),[p2]); +assert.deepEqual(ok('luis','listarProyectosRequisicion','plt').map(p=>p.folio),[p2],'por clave'); +assert.deepEqual(ok('luis','listarProyectosRequisicion',p2+'-PLT').map(p=>p.folio),[p2],'por folio con clave'); assert.deepEqual(ok('luis','listarProyectosRequisicion','Cliente Secreto'),[],'no busca por cliente'); // --- Crear: cotización obligatoria (PDF, JPG, PNG o HEIC, máx. 10 MB); folio REQ; carpeta privada; Solicitada --- @@ -198,7 +203,10 @@ falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:null})],/Adjunta falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.from('hola mundo txt').toString('base64')}})],/La cotización debe ser PDF o imagen/); falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.concat([Buffer.from('%PDF-'),Buffer.alloc(10*1024*1024)]).toString('base64')}})],/pesa más de 10 MB/); falla('obrero','guardarRequisicion',[nueva('obrero',{renglones:[{descripcion:'A',cantidad:0}]})],/mayor a 0/); -falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/activos con contrato firmado/); +const filaP3=db.pest.Proyectos.m.findIndex(x=>x[0]===p3); +db.pest.Proyectos.m[filaP3][6]='Cancelado'; +falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/está cancelado: no se piden materiales/); +db.pest.Proyectos.m[filaP3][6]='Activo'; falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/); assert.equal(props['CONSECUTIVO_REQ_'+ANIO],undefined,'los rechazos no gastan folio'); let r=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'})); @@ -376,4 +384,64 @@ for(const a of ['Solicitar requisición','Devolver requisición','Reenviar requi assert.equal(H('Bitacora').slice(1).find(b=>b[2]==='Cancelar pago a proveedor')[5],'Se capturó con el tipo de cambio equivocado'); assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de requisiciones en la Base de datos'); assert(!JSON.stringify(db.pest).includes('REQ-')&&!JSON.stringify(db.pest).includes('PGP-')); + +// ================= Carga inicial (bootstrap) y sincronización sin recargar ================= +// bootstrap: una sola llamada con los roles y las listas que necesita cada rol +const bE=ok('efrain','bootstrap'), bP=ok('pedro','bootstrap'), bA=ok('ana','bootstrap'), bL=ok('luis','bootstrap'); +assert.deepEqual([!!bE.cob,!!bE.com,!!bE.req],[true,true,false],'efrain: cobranza y compras'); +assert(Array.isArray(bE.cob.proyectos)&&Array.isArray(bE.com.proyectos)); +// Las listas pesadas solo van si ya están en caché (con el caché vacío, la interfaz las pide al abrir la pestaña) +assert.equal(bE.com.requisiciones,null,'en frío: sin la lista de requisiciones'); +ok('efrain','listarRequisiciones','',''); +assert(Array.isArray(ok('efrain','bootstrap').com.requisiciones),'ya en caché: viene en el bootstrap'); +assert.deepEqual([!!bP.cob,!!bP.com,!!bP.req],[false,true,true],'pedro: compras y requisiciones'); +assert.deepEqual([!!bA.cob,!!bA.com,!!bA.req,bA.proyectos.length>0,bA.catalogo[0].precio],[false,false,false,true,4100],'ana: solo cotizar'); +assert.deepEqual([bL.soloRequisicion,!!bL.req,!!bL.com,!!bL.cob,bL.clientes.length,bL.proyectos.length,bL.tipoCambio],[true,true,false,false,0,0,null]); +assert(!/Cliente Secreto|Empresa Oculta|4100|98765/.test(JSON.stringify(bL)),'SOLO_REQUISICION: sin clientes, precios ni montos'); +assert.deepEqual(Object.keys(bE.versiones).sort(),['catalogo','contratos','cotizaciones','oc','pagosCobranza','pagosProveedores','proyectos','requisiciones']); +// Sin cambios: solo versiones y permisos (ninguna lista) +let sP=ok('pedro','sincronizar',bP.versiones,{}); +assert.deepEqual([sP.cambios.length,'comRequisiciones' in sP,'reqProyectos' in sP,sP.permisos.compras],[0,false,false,true]); +// Ana crea un proyecto: los demás lo ven en su siguiente consulta, cada quien en sus listas +const p9=proy('Nave Industrial','p9',''); +sP=ok('pedro','sincronizar',bP.versiones,{}); +assert.deepEqual([...sP.cambios],['proyectos']); +assert(sP.comProyectos.some(p=>p.folio===p9&&p.folioVisible===p9+'-NAI'&&p.sinContrato),'compras: el proyecto nuevo, sin contrato'); +assert(sP.reqProyectos.some(p=>p.folio===p9),'requisiciones: el proyecto nuevo'); +assert(!('cobProyectos' in sP)&&!('proyectos' in sP&&sP.proyectos==null),'pedro no recibe cobranza'); +const sE=ok('efrain','sincronizar',bE.versiones,{cobResumen:true}); +assert(sE.cobProyectos.some(p=>p.folio===p9)&&sE.comProyectos.some(p=>p.folio===p9)); +const sA=ok('ana','sincronizar',bA.versiones,{}); +assert(sA.proyectos.some(p=>p.folio===p9)&&!('comProyectos' in sA)&&!('misReq' in sA)&&!('cobProyectos' in sA)); +const sL=ok('luis','sincronizar',bL.versiones,{req:REQ('0001'),cobProyecto:p1,comProyecto:p1}); +assert(sL.reqProyectos.some(p=>p.folio===p9)&&!('proyectos' in sL)&&!('comRequisiciones' in sL)&&!('requisicion' in sL)&&!('cobranzaProyecto' in sL), + 'SOLO_REQUISICION: aunque pida otras vistas, solo recibe lo suyo'); +assert(!/Cliente Secreto|Empresa Oculta/.test(JSON.stringify(sL))); +// Una requisición nueva: compras recibe su lista y la requisición abierta; el solicitante solo las suyas +const vAntes=ok('pedro','sincronizar',{},{}).versiones; +const rq9=ok('obrero','guardarRequisicion',{proyectoFolio:p9+'-NAI',renglones:[{descripcion:'Lámina',cantidad:3}], + cotizacion:{nombre:'c.pdf',datos:PDF},idSolicitud:'rq9'}).folio; +assert.equal(H('Requisiciones').find(x=>x[0]===rq9)[2],p9,'con el folio visible se guarda la llave interna'); +const sP2=ok('pedro','sincronizar',vAntes,{req:rq9,rcEstado:'Solicitada'}); +assert.deepEqual([...sP2.cambios],['requisiciones']); +assert(sP2.comRequisiciones.some(r=>r.folio===rq9&&r.proyectoVisible===p9+'-NAI')&&sP2.comRequisiciones.every(r=>r.estado==='Solicitada')); +assert.equal(sP2.requisicion.requisicion.folio,rq9); +const sO=ok('obrero','sincronizar',vAntes,{}); +assert(sO.misReq.some(r=>r.folio===rq9)&&!('comRequisiciones' in sO)); +assert(!ok('rosa','sincronizar',vAntes,{}).misReq.some(r=>r.folio===rq9),'rosa no ve la requisición de obrero'); +// Las listas salen del caché hasta que algo cambia (y la escritura sube la versión) +assert(Object.keys(cache).some(k=>k.startsWith('K_reqs_')),'lista de requisiciones en caché'); +// Cambio a mano en la hoja (Estado): la carga inicial no relee la hoja; la consulta de fondo sí, cada 5 min +const fila9=db.pest.Proyectos.m.findIndex(x=>x[0]===p9); db.pest.Proyectos.m[fila9][6]='Cancelado'; +const vMan=ok('pedro','sincronizar',{},{}).versiones; +assert(ok('pedro','bootstrap').com.proyectos.some(p=>p.folio===p9),'antes de la revisión sigue el dato en caché'); +delete cache['R_proyectos']; // pasan los 5 min de la revisión +const sMan=ok('pedro','sincronizar',vMan,{}); +assert.deepEqual([...sMan.cambios],['proyectos'],'la huella cambió: sube la versión'); +assert(!sMan.comProyectos.some(p=>p.folio===p9)&&!sMan.reqProyectos.some(p=>p.folio===p9),'cancelado a mano: sale de las listas'); +assert.deepEqual([...ok('pedro','sincronizar',sMan.versiones,{}).cambios],[],'una vez avisado, no se repite'); +// Cambio de rol sin recargar la página: la siguiente consulta trae los permisos nuevos +props.ROL_COMPRAS+=', tania@helgen.tech'; +assert.equal(ok('tania','sincronizar',vAntes,{}).permisos.compras,true); +falla('intruso','sincronizar',[{},{}],/@helgen.tech/,'SESION'); console.log('OK requisiciones'); diff --git a/tests/sat.test.js b/tests/sat.test.js index 0365285..3e89cb1 100644 --- a/tests/sat.test.js +++ b/tests/sat.test.js @@ -24,11 +24,11 @@ const libros={db:libro('db',[cot,cfg]),cat:libro('cat',[cat])}; let nuevo=0; const props={SPREADSHEET_ID:'db',FOLDER_ID:'f',CATALOGO_ID:'cat'},cache={}; const archivos={'SAT_c_ClaveProdServ.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveProdServ.csv','utf8'),'SAT_c_ClaveUnidad.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveUnidad.csv','utf8')}; const ctx={console,Date, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, - Utilities:{parseCsv,formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, + Utilities:{parseCsv,formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, SpreadsheetApp:{flush(){},newDataValidation:()=>chain,create:n=>{nuevo++;const l=libro('sat',[hoja('Hoja 1',[])]);libros.sat=l;return l},openById:id=>libros[id]}, DriveApp:{getFilesByName:n=>{let d=false;return{hasNext:()=>!d&&!!archivos[n],next:()=>{d=true;return{isTrashed:()=>false,getParents:()=>({next:()=>({})}),getBlob:()=>({getDataAsString:()=>archivos[n]})}}}}, getFileById:()=>({moveTo(){},getParents:()=>({hasNext:()=>false})}), diff --git a/tests/sesion.test.js b/tests/sesion.test.js index 9274c68..aad5db5 100644 --- a/tests/sesion.test.js +++ b/tests/sesion.test.js @@ -23,13 +23,13 @@ const tokens={'t-bueno':BUENO,'t-gmail':{...BUENO,email:'ana@gmail.com',hd:undef 't-otro-dominio':{...BUENO,email:'x@otra.com',hd:'otra.com'}}; let fetches=0; const ctx={console,Date,JSON, - PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, - CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, + PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})}, + CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, Session:{getActiveUser:()=>({getEmail:()=>''})}, // despliegue anónimo: Google no da el correo Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'19/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}), - base64Encode:b=>Buffer.from(b).toString('base64'),DigestAlgorithm:{SHA_256:'sha256'}, - computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, + base64Encode:b=>Buffer.from(b).toString('base64'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'), + computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]}, UrlFetchApp:{fetch:url=>{fetches++;const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})}, diff --git a/web/cotizador/index.html b/web/cotizador/index.html index 203ece5..b1456c7 100644 --- a/web/cotizador/index.html +++ b/web/cotizador/index.html @@ -165,7 +165,7 @@
- +
@@ -174,6 +174,8 @@
Nuevo proyecto · cliente:
+
+
El folio será PRY-AAAA-NNNN-XXX. La clave ya no se cambia después.
@@ -281,7 +283,7 @@

1 · Resumen de cobranza

-
+
Cargando…
@@ -318,7 +320,7 @@

1 · Proyecto y contrato

- +
@@ -380,8 +382,8 @@

1 · Nueva requisición

-
- +
+
@@ -426,15 +428,15 @@
- +
FolioFechaProyectoSolicitanteRenglonesEstadoOrden
Cargando…
Toda orden de compra sale de una requisición: ábrela para autorizarla, pagarla o cerrarla.
-
+

2 · Órdenes por proyecto

-
- +
+
@@ -578,7 +580,7 @@ const $ = id => document.getElementById(id); // ---- Sesión ---- - let token = null, expira = 0, gisListo = false; + let token = null, expira = 0, gisListo = false, correo = ''; const pendientes = []; // llamadas que esperan a que se inicie sesión // Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token @@ -598,13 +600,15 @@ function guardarSesion(t) { const datos = leerToken(t); - token = t; expira = Number(datos.exp) || 0; + token = t; expira = Number(datos.exp) || 0; correo = String(datos.email || '').toLowerCase(); try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {} pintarUsuario(datos); + // La interfaz dibuja al instante las pestañas de la última sesión de este correo (solo interfaz) + document.dispatchEvent(new CustomEvent('sesion-lista', { detail: correo })); } function olvidarSesion() { - token = null; expira = 0; + token = null; expira = 0; correo = ''; try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {} $('usuario').classList.add('oculto'); } @@ -685,6 +689,9 @@ const script = { run: corredor(null, null) }; window.google = window.google || {}; google.script = script; + // Para la sincronización: no consultar sin sesión (si no, las consultas se forman en cola y abren el inicio de sesión) + window.sesionVigente = vigente; + window.correoSesion = () => correo; // ---- Google Identity Services (se carga con el