mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
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:
parent
f6476c3230
commit
c04f878168
@ -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
12
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();
|
||||
|
||||
81
tests/interfaz.test.js
Normal file
81
tests/interfaz.test.js
Normal 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)});
|
||||
@ -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 ---
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user