diff --git a/CLAUDE.md b/CLAUDE.md index 49fe0bf..a4a38df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -316,12 +316,13 @@ Actualizado, Actualizó, Clave SAT, Unidad SAT. A:C y G:H en formato texto. ## 8. Cómo trabajar ```bash -# Pruebas (Node, servicios de Google simulados). Correr las cinco antes de subir. +# Pruebas (Node, servicios de Google simulados). Correr las seis antes de subir. mkdir -p /tmp/salida-pdf && node tests/cotizacion.test.js Code.js /tmp/salida-pdf # folios, idempotencia, marcas, Config, clientes; deja helgen.html / energy.html node tests/catalogo.test.js . # catálogo: migración, alta, edición, duplicados node tests/sat.test.js . "$HOME/Library/CloudStorage/GoogleDrive-efrain@helgen.tech/Mi unidad/Cotizador Helgen/Catálogos SAT" node tests/sesion.test.js . # token en doPost, vendedor_* (vendedor falso se ignora) node tests/proyectos.test.js . # folio PRY, carpetas, proyecto_folio, subcarpetas que se reparan +node tests/interfaz.test.js . # interfaz con DOM y fetch simulados: proyecto rechazado se limpia node tests/probar-busqueda-sat.js Code.js "/SAT_c_ClaveProdServ.csv" # ver qué sugiere la búsqueda por palabras # Subir y publicar la API (misma API_URL) @@ -392,6 +393,8 @@ El commit y el push no publican nada: el equipo sigue usando la versión anterio (~1 h); las llamadas sin sesión vigente esperan en cola hasta que se inicia sesión. - Si el servidor responde `codigo: "SESION"`, se borra el token, se muestra el login y la llamada se repite **una vez** al entrar (guardar es idempotente por `idSolicitud`: no duplica folio). +- Otros códigos llegan a la interfaz en `err.codigo`. `codigo: "PROYECTO"` (proyecto cancelado, cerrado o inexistente al guardar, + `errorProyecto_`): `errorAlGuardar` limpia el campo Proyecto, refresca la lista y muestra el aviso. - Encabezado: nombre del vendedor (del token, solo para mostrar) + botón "Cerrar sesión" (`disableAutoSelect`). - Tras publicar, el navegador puede mostrar la versión anterior en caché: recargar la página. - Wrangler 4 delega Cloudflare Pages "clásico" a Workers: se eligió **Worker con assets** (`wrangler.jsonc`), no `wrangler pages`. diff --git a/Code.js b/Code.js index a150397..f650a96 100644 --- a/Code.js +++ b/Code.js @@ -124,6 +124,7 @@ function doPost(e) { } 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; // p. ej. 'PROYECTO' (ver errorProyecto_) } finally { VENDEDOR_ = null; } @@ -636,9 +637,9 @@ function guardarCotizacion(payload) { let carpetaPdf = null; if (proyectoFolio) { const proyecto = buscarProyecto_(hojaProyectos_(), proyectoFolio); - if (!proyecto) throw new Error('No existe el proyecto ' + proyectoFolio + '. Elige otro o cotiza sin proyecto.'); + if (!proyecto) throw errorProyecto_('No existe el proyecto ' + proyectoFolio + '. Elige otro o cotiza sin proyecto.'); if (proyecto.estado !== 'Activo') { - throw new Error('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() + + throw errorProyecto_('El proyecto ' + proyectoFolio + ' está ' + proyecto.estado.toLowerCase() + ': ya no se le pueden agregar cotizaciones.'); } carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones'); @@ -775,6 +776,13 @@ function listarProyectos(busqueda, incluirInactivos) { ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha })); } +/** Error de proyecto inexistente o no Activo: doPost lo manda con codigo 'PROYECTO' y la interfaz limpia el campo. */ +function errorProyecto_(mensaje) { + const e = new Error(mensaje); + e.codigo = 'PROYECTO'; + return e; +} + /** Proyecto por folio (con su número de fila), o null. */ function buscarProyecto_(hoja, folio) { const buscado = String(folio || '').trim().toUpperCase(); diff --git a/tests/interfaz.test.js b/tests/interfaz.test.js new file mode 100644 index 0000000..f42bace --- /dev/null +++ b/tests/interfaz.test.js @@ -0,0 +1,81 @@ +// Interfaz (web/cotizador/index.html): corre la capa api-web y el script de la app con un DOM y un fetch simulados. +// Cubre: si el servidor rechaza el proyecto (codigo PROYECTO), se limpia el campo, se refresca la lista y se avisa. +// Uso: node tests/interfaz.test.js . (carpeta del proyecto) +const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path'); +const html=fs.readFileSync(path.join(process.argv[2],'web/cotizador/index.html'),'utf8'); +const scripts=[...html.matchAll(/([\s\S]*?)<\/script>/g)].map(m=>({id:m[1],src:m[2]})); +const apiWeb=scripts.find(s=>s.id==='api-web').src, app=scripts.find(s=>!s.id).src; + +// ---- DOM simulado (solo lo que usa la app) ---- +function elemento(tag){const e={tag,value:'',textContent:'',innerHTML:'',className:'',title:'',src:'',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), + querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')), + querySelectorAll:sel=>sel==='tr'?e.hijos:[]};return e} +const porId={}; +const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')), + querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)}; +document.getElementById('login').classList.add('oculto'); + +// ---- Sesión vigente (token falso; quien valida es el servidor) ---- +const b64=o=>Buffer.from(JSON.stringify(o)).toString('base64url'); +const token='h.'+b64({email:'ana@helgen.tech',given_name:'Ana',exp:Math.floor(Date.now()/1000)+3600})+'.f'; +const almacen={cotizadorHelgenSesion:token}; + +// ---- Servidor simulado ---- +const PRY={folio:'PRY-2026-0001',nombre:'Techo',cliente:'Luis — Solar MX',clienteId:'LUSM800101AB1',ubicacion:'Qro',estado:'Activo',fecha:'20/10/2026'}; +const llamadas=[]; let respuestas={}; +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}} +respuestas.getDatosIniciales={ok:true,datos:{ivaTasa:0.16,catalogo:[],clientes:[],proyectos:[PRY],marcas:{}}}; + +const ctx={document,fetch,console,JSON,Date,Math,Number,String,Array,Object,Promise,Proxy,Error,Uint8Array,TextDecoder, + 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]}, + setTimeout,clearTimeout,scrollTo(){}}; +ctx.window=ctx; +vm.createContext(ctx); +vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx); +const $=id=>document.getElementById(id), esperar=()=>new Promise(r=>setImmediate(r)); + +(async()=>{ + for(let i=0;i<5;i++) await esperar(); + assert.equal(vm.runInContext('PROYECTOS.length',ctx),1,'carga los proyectos activos'); + + // Elegir el proyecto + $('cNombre').value='Luis'; + $('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change'); + assert(/^Proyecto activo/.test($('proyInfo').textContent)); + + // El servidor lo rechaza (se canceló mientras tanto): codigo PROYECTO + respuestas.guardarCotizacion={ok:false,codigo:'PROYECTO',error:'El proyecto PRY-2026-0001 está cancelado: ya no se le pueden agregar cotizaciones.'}; + respuestas.listarProyectos={ok:true,datos:[]}; + vm.runInContext('guardar()',ctx); + for(let i=0;i<10;i++) await esperar(); + const g=llamadas.find(c=>c.funcion==='guardarCotizacion'); + assert.equal(g.args[0].proyectoFolio,'PRY-2026-0001'); assert.equal(g.token,token); + assert.equal($('proyecto').value,'','se limpia el campo Proyecto'); + assert(/^Sin proyecto/.test($('proyInfo').textContent),'la etiqueta ya no dice "Proyecto activo": '+$('proyInfo').textContent); + assert(llamadas.some(c=>c.funcion==='listarProyectos'),'se refresca la lista de proyectos'); + assert.equal(vm.runInContext('PROYECTOS.length',ctx),0); assert.equal($('proyectosList').innerHTML,''); + assert.equal($('aviso').className,'aviso error'); + assert(/está cancelado/.test($('aviso').innerHTML)&&/Se quitó el proyecto/.test($('aviso').innerHTML)); + assert.equal($('btnGuardar').disabled,false,'se puede volver a guardar'); + + // Otro error (no de proyecto): el campo se queda como estaba + respuestas.listarProyectos={ok:true,datos:[PRY]}; vm.runInContext("recibirProyectos([PRY_])".replace('PRY_',JSON.stringify(PRY)),ctx); + $('proyecto').value='PRY-2026-0001 — Luis — Solar MX — Techo'; $('proyecto').disparar('change'); + respuestas.guardarCotizacion={ok:false,error:'El sistema está ocupado guardando otra cotización.'}; + const antes=llamadas.filter(c=>c.funcion==='listarProyectos').length; + vm.runInContext('guardar()',ctx); + for(let i=0;i<10;i++) await esperar(); + assert.equal($('proyecto').value,'PRY-2026-0001 — Luis — Solar MX — Techo'); + assert(/^Proyecto activo/.test($('proyInfo').textContent)); + assert.equal(llamadas.filter(c=>c.funcion==='listarProyectos').length,antes); + assert(/ocupado/.test($('aviso').innerHTML)&&!/Se quitó/.test($('aviso').innerHTML)); + 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 1d44448..a661273 100644 --- a/tests/proyectos.test.js +++ b/tests/proyectos.test.js @@ -116,8 +116,10 @@ const h=ctx.listarCotizaciones(''); assert.deepEqual(h.map(c=>c.proyecto),['Sin proyecto','PRY-2026-0001','Sin proyecto']); 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'}),/No existe el proyecto PRY-2026-0099/); -assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0003',idSolicitud:'c4'}),/está cancelado/); +assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0099',idSolicitud:'c3'}), + e=>/No existe el proyecto PRY-2026-0099/.test(e.message)&&e.codigo==='PROYECTO'); +assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0003',idSolicitud:'c4'}), + e=>/está cancelado/.test(e.message)&&e.codigo==='PROYECTO','la interfaz usa codigo PROYECTO para limpiar el campo'); assert.equal(props.CONSECUTIVO_2026,'6'); assert.equal(cot.m.length,4); // --- Subcarpetas borradas o renombradas: se crea una nueva --- diff --git a/tests/sesion.test.js b/tests/sesion.test.js index 4a7b639..1f0a7c4 100644 --- a/tests/sesion.test.js +++ b/tests/sesion.test.js @@ -91,6 +91,9 @@ assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pest assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos'); assert.equal(h[1].proyecto,'Sin proyecto'); assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/); +// Proyecto inexistente: doPost manda codigo PROYECTO (la interfaz quita el proyecto del campo); no es error de sesión +const rp=post({funcion:'guardarCotizacion',args:[{...base,proyectoFolio:'PRY-2026-0099',idSolicitud:'pry-x'}],token:'t-bueno'}); +assert.equal(rp.ok,false); assert.equal(rp.codigo,'PROYECTO'); assert(/No existe el proyecto/.test(rp.error)); // Fuera de doPost (sin token) guardar se rechaza assert.throws(()=>ctx.guardarCotizacion({...base,idSolicitud:'directo'}),/Inicia sesión/); // doGet ya no sirve la app diff --git a/web/cotizador/index.html b/web/cotizador/index.html index 398aa64..0b2dd94 100644 --- a/web/cotizador/index.html +++ b/web/cotizador/index.html @@ -323,7 +323,11 @@ // ---- Llamadas al servidor ---- function llamar(funcion, args, alExito, alFallar, esReintento) { - const fallar = msg => { if (alFallar) alFallar(new Error(msg)); else console.error(msg); }; + // El error lleva el codigo del servidor (p. ej. 'PROYECTO') para que la interfaz reaccione + const fallar = (msg, codigo) => { + const e = new Error(msg); if (codigo) e.codigo = codigo; + if (alFallar) alFallar(e); else console.error(msg); + }; if (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; } if (!vigente()) { pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento)); @@ -348,7 +352,7 @@ } pedirSesion(res.error); } - fallar((res && res.error) || 'Respuesta inválida del servidor.'); + fallar((res && res.error) || 'Respuesta inválida del servidor.', res && res.codigo); }); } @@ -496,10 +500,21 @@ ' · Abrir PDF'); limpiar(); }) - .withFailureHandler(mostrarError) + .withFailureHandler(errorAlGuardar) .guardarCotizacion(payload); } + // Si el servidor rechaza el proyecto (cancelado, cerrado o inexistente): se quita del campo y se refresca la lista + function errorAlGuardar(err) { + if (err && err.codigo === 'PROYECTO') { + $('proyecto').value = ''; pintarProyInfo(); + google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); + mostrarError((err.message || '') + ' Se quitó el proyecto: elige otro o guarda sin proyecto.'); + return; + } + mostrarError(err); + } + function limpiar() { ['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = ''); $('filas').innerHTML = ''; agregarFila(); recalcular();