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
|
## 8. Cómo trabajar
|
||||||
|
|
||||||
```bash
|
```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
|
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/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/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/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/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
|
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)
|
# 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.
|
(~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
|
- 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).
|
(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`).
|
- 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.
|
- 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`.
|
- 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) {
|
} catch (err) {
|
||||||
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
|
respuesta = { ok: false, error: err && err.message ? err.message : String(err) };
|
||||||
if (err && err.sesion) respuesta.codigo = 'SESION';
|
if (err && err.sesion) respuesta.codigo = 'SESION';
|
||||||
|
else if (err && err.codigo) respuesta.codigo = err.codigo; // p. ej. 'PROYECTO' (ver errorProyecto_)
|
||||||
} finally {
|
} finally {
|
||||||
VENDEDOR_ = null;
|
VENDEDOR_ = null;
|
||||||
}
|
}
|
||||||
@ -636,9 +637,9 @@ function guardarCotizacion(payload) {
|
|||||||
let carpetaPdf = null;
|
let carpetaPdf = null;
|
||||||
if (proyectoFolio) {
|
if (proyectoFolio) {
|
||||||
const proyecto = buscarProyecto_(hojaProyectos_(), 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') {
|
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.');
|
': ya no se le pueden agregar cotizaciones.');
|
||||||
}
|
}
|
||||||
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
|
carpetaPdf = subcarpetaProyecto_(proyecto, 'Cotizaciones');
|
||||||
@ -775,6 +776,13 @@ function listarProyectos(busqueda, incluirInactivos) {
|
|||||||
ubicacion: p.ubicacion, estado: p.estado, fecha: p.fecha }));
|
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. */
|
/** Proyecto por folio (con su número de fila), o null. */
|
||||||
function buscarProyecto_(hoja, folio) {
|
function buscarProyecto_(hoja, folio) {
|
||||||
const buscado = String(folio || '').trim().toUpperCase();
|
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(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');
|
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
|
// 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-0099',idSolicitud:'c3'}),
|
||||||
assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0003',idSolicitud:'c4'}),/está cancelado/);
|
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);
|
assert.equal(props.CONSECUTIVO_2026,'6'); assert.equal(cot.m.length,4);
|
||||||
|
|
||||||
// --- Subcarpetas borradas o renombradas: se crea una nueva ---
|
// --- 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(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.equal(h[1].proyecto,'Sin proyecto');
|
||||||
assert.throws(()=>ctx.crearProyecto({nombre:'X',cliente:{nombre:'Y'}}),/Inicia sesión/);
|
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
|
// Fuera de doPost (sin token) guardar se rechaza
|
||||||
assert.throws(()=>ctx.guardarCotizacion({...base,idSolicitud:'directo'}),/Inicia sesión/);
|
assert.throws(()=>ctx.guardarCotizacion({...base,idSolicitud:'directo'}),/Inicia sesión/);
|
||||||
// doGet ya no sirve la app
|
// doGet ya no sirve la app
|
||||||
|
|||||||
@ -323,7 +323,11 @@
|
|||||||
|
|
||||||
// ---- Llamadas al servidor ----
|
// ---- Llamadas al servidor ----
|
||||||
function llamar(funcion, args, alExito, alFallar, esReintento) {
|
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 (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; }
|
||||||
if (!vigente()) {
|
if (!vigente()) {
|
||||||
pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento));
|
pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento));
|
||||||
@ -348,7 +352,7 @@
|
|||||||
}
|
}
|
||||||
pedirSesion(res.error);
|
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>');
|
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
|
||||||
limpiar();
|
limpiar();
|
||||||
})
|
})
|
||||||
.withFailureHandler(mostrarError)
|
.withFailureHandler(errorAlGuardar)
|
||||||
.guardarCotizacion(payload);
|
.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() {
|
function limpiar() {
|
||||||
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
|
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
|
||||||
$('filas').innerHTML = ''; agregarFila(); recalcular();
|
$('filas').innerHTML = ''; agregarFila(); recalcular();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user