Proyecto rechazado al guardar: la interfaz limpia el campo y refresca la lista

Si el servidor rechaza la cotización porque el proyecto se canceló, se cerró o no existe, la etiqueta seguía
diciendo "Proyecto activo". Ahora el error lleva codigo PROYECTO (errorProyecto_ en Code.js, doPost lo
pasa a la respuesta, la capa api-web lo pone en err.codigo) y errorAlGuardar limpia el campo Proyecto,
refresca la lista de proyectos y muestra el aviso. Otros errores no tocan el campo.

Pruebas: tests/interfaz.test.js (nueva: DOM y fetch simulados), codigo PROYECTO en proyectos.test.js y
sesion.test.js. CLAUDE.md §8 y §10.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 12:29:56 -06:00
parent f6476c3230
commit c04f878168
6 changed files with 120 additions and 8 deletions

View File

@ -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 "<ruta>/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`.

12
Code.js
View File

@ -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();

81
tests/interfaz.test.js Normal file
View File

@ -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(/<script(?: id="([^"]*)")?>([\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)});

View File

@ -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 ---

View File

@ -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

View File

@ -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 @@
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
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();