mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
- Rol nuevo ROL_COMPRAS (Script Properties): solo ese rol crea, ve, recibe y cancela órdenes; el servidor lo revisa en cada llamada y los demás reciben codigo PERMISO. getDatosIniciales agrega permisos.compras. - Órdenes OC-AAAA-NNNN con el mismo candado e idempotencia, solo en proyectos Activos con contrato firmado. Estados Emitida / Recibida / Cancelada (con motivo; nunca se borran). La línea de negocio sale del contrato. - Libro y carpeta "Cotizador Helgen — Compras" aparte y restringidos con privado_(): ni en la Base de datos ni en la carpeta del proyecto ni en cobranza. Pestañas OrdenesCompra, Proveedores (se llena sola) y Bitacora. - PDF de la orden siempre con la razón social de Helgen y los términos TERMINOS_OC (se agregan solos a Config). Sin tipo de cambio ni línea. - Costo siempre capturado, nunca el precio de venta. Orden en otra moneda que el proyecto: tipo de cambio obligatorio y costo convertido. - Margen (contratado sin IVA − órdenes vigentes sin IVA) solo en la vista de cobranza; preparado para sumar nómina y horas. Con órdenes vigentes la aceptada no puede cambiar de moneda. - Cobranza y compras comparten la mecánica de área privada (carpetaArea_, libroArea_, enRol_). Los proyectos nuevos ya no crean "Órdenes de compra". - Interfaz: pestaña Compras y tarjeta de margen en Cobranza. - Pruebas: tests/compras.test.js nuevo; interfaz, cobranza, sesión, proyectos y cotización actualizadas (oc.html de ejemplo). Las ocho pasan. Sin publicar: falta respaldo, prueba en PRUEBAS, clasp y wrangler (CLAUDE.md §9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
9.5 KiB
JavaScript
116 lines
9.5 KiB
JavaScript
// Sesión con Google en doPost y columnas vendedor_*.
|
|
// Uso: node tests/sesion.test.js . (carpeta con Code.js y Logo.js)
|
|
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
|
const dir=process.argv[2];
|
|
let src=fs.readFileSync(path.join(dir,'Logo.js'),'utf8')+'\n'+fs.readFileSync(path.join(dir,'Code.js'),'utf8');
|
|
const CID='123-abc.apps.googleusercontent.com';
|
|
src=src.replace(/const CLIENT_ID_GOOGLE = '[^']*';/,"const CLIENT_ID_GOOGLE = '"+CID+"';");
|
|
function hoja(m){return{m,getLastRow:()=>m.length,getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'19/10/2026':String(x??'')))}),
|
|
appendRow:a=>m.push(a),
|
|
getRange:(r,c,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){}}:({
|
|
getValues:()=>Array.from({length:nr},(_,i)=>{const f=(m[r-1+i]||[]).slice(c-1,c-1+nc);while(f.length<nc)f.push('');return f}),
|
|
setValues(vs){vs.forEach((x,i)=>{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}})}}
|
|
const cfg=hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]);
|
|
// Hoja con una fila vieja (sin columnas vendedor_*)
|
|
const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas','Marca','Dirección'],
|
|
['HLG-2026-0001','x','Viejo','','','','',1,0.16,1.16,'antes@helgen.tech','u','[]','','Helgen','']]);
|
|
let pry=null;
|
|
const props={SPREADSHEET_ID:'x',FOLDER_ID:'y',CATALOGO_ID:'c'},cache={};
|
|
const ahora=Math.floor(Date.now()/1000);
|
|
const BUENO={aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email:'Ana.Lopez@helgen.tech',email_verified:'true',hd:'helgen.tech',given_name:'Ana',family_name:'López Díaz'};
|
|
const tokens={'t-bueno':BUENO,'t-gmail':{...BUENO,email:'ana@gmail.com',hd:undefined},'t-otro-aud':{...BUENO,aud:'otro.apps.googleusercontent.com'},
|
|
'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})},
|
|
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()]},
|
|
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}})},
|
|
SpreadsheetApp:{flush(){},openById:id=>id==='c'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:{getSheetByName:n=>n==='Config'?cfg:n==='Proyectos'?pry:cot,
|
|
insertSheet:n=>pry=Object.assign(hoja([]),{setFrozenRows(){},getMaxRows:()=>1000,setColumnWidth(){}})},
|
|
newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}},
|
|
DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:()=>({getUrl:()=>'u'})})})})},
|
|
};
|
|
vm.createContext(ctx); vm.runInContext(src,ctx);
|
|
const post=(cuerpo)=>JSON.parse(ctx.doPost({postData:{contents:typeof cuerpo==='string'?cuerpo:JSON.stringify(cuerpo)}}).t);
|
|
const base={cliente:{nombre:'Cliente Uno'},items:[{descripcion:'Servicio',cantidad:1,precio:100}]};
|
|
|
|
// --- validarClaimsToken (lógica pura) ---
|
|
const v=(c)=>ctx.validarClaimsToken(c,CID,'helgen.tech',ahora);
|
|
assert.deepEqual({...v(BUENO)},{email:'ana.lopez@helgen.tech',nombre:'Ana',apellido:'López Díaz'});
|
|
assert.deepEqual({...v({...BUENO,given_name:'',family_name:'',name:'Luis Pérez Gómez'})},{email:'ana.lopez@helgen.tech',nombre:'Luis',apellido:'Pérez Gómez'});
|
|
assert.equal(v({...BUENO,email_verified:true}).email,'ana.lopez@helgen.tech');
|
|
for(const [malo,msg] of [[{aud:'otro'},/no corresponde/],[{iss:'evil.com'},/inválida/],[{exp:String(ahora-1)},/expiró/],
|
|
[{hd:undefined,email:'ana@gmail.com'},/@helgen.tech/],[{hd:'otra.com'},/@helgen.tech/],[{email_verified:'false'},/@helgen.tech/],
|
|
[{email:'ana@helgen.tech.mx'},/@helgen.tech/]]){
|
|
assert.throws(()=>v({...BUENO,...malo}),e=>e.sesion===true&&msg.test(e.message),JSON.stringify(malo));
|
|
}
|
|
|
|
// --- doPost rechaza sin token válido y no ejecuta nada ---
|
|
const filasAntes=cot.m.length;
|
|
for(const tok of [undefined,'','t-falso','t-gmail','t-otro-aud','t-otro-dominio']){
|
|
const r=post({funcion:'guardarCotizacion',args:[{...base,idSolicitud:'x-'+tok}],token:tok});
|
|
assert.equal(r.ok,false,String(tok)); assert.equal(r.codigo,'SESION',String(tok));
|
|
}
|
|
assert.equal(cot.m.length,filasAntes,'sin sesión no se guarda nada');
|
|
assert.equal(post('no es json').ok,false);
|
|
assert.equal(post({funcion:'setup',token:'t-bueno'}).error,'Función no permitida: setup.');
|
|
assert.equal(post({funcion:'guardarCotizacion',args:[base]}).codigo,'SESION');
|
|
assert.equal(post({funcion:'crearProyecto',args:[{nombre:'X',cliente:{nombre:'Y'}}]}).codigo,'SESION');
|
|
assert.equal(post({funcion:'listarProyectos',args:['']}).codigo,'SESION');
|
|
|
|
// --- Guardar con token válido: vendedor del token, aunque el navegador mande otro ---
|
|
fetches=0;
|
|
const r=post({funcion:'guardarCotizacion',token:'t-bueno',
|
|
args:[{...base,idSolicitud:'ok1',vendedor_email:'impostor@helgen.tech',vendedor:{email:'impostor@helgen.tech',nombre:'X'},elaboro:'impostor@helgen.tech'}]});
|
|
assert(r.ok,r.error); assert.equal(r.datos.folio,'HLG-2026-0002');
|
|
const enc=cot.m[0]; assert.deepEqual(enc.slice(16),['vendedor_email','vendedor_nombre','vendedor_apellido','proyecto_folio','moneda','tipo_cambio']);
|
|
const fila=cot.m[cot.m.length-1];
|
|
assert.equal(fila[10],'ana.lopez@helgen.tech','Elaboró = correo del token');
|
|
assert.deepEqual(fila.slice(16),['ana.lopez@helgen.tech','Ana','López Díaz','','MXN',''],'sin proyecto = columna T vacía; sin moneda = MXN');
|
|
assert(!JSON.stringify(fila).includes('impostor'));
|
|
// Caché: el token ya se verificó (en la llamada a setup); no se vuelve a consultar a Google
|
|
assert.equal(post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).ok,true); assert.equal(fetches,0);
|
|
// Reenvío del mismo formulario = mismo folio (idempotencia intacta)
|
|
assert.equal(post({funcion:'guardarCotizacion',args:[{...base,idSolicitud:'ok1'}],token:'t-bueno'}).datos.folio,'HLG-2026-0002');
|
|
|
|
// --- Historial con el nombre del vendedor; filas viejas muestran el correo de "Elaboró" ---
|
|
const h=post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).datos;
|
|
assert.equal(h[0].vendedor,'Ana López Díaz'); assert.equal(h[1].vendedor,'antes@helgen.tech');
|
|
// Filas viejas sin nombre: si su correo aparece con nombre en otra fila, se muestra ese nombre (sin tocar la hoja)
|
|
const vieja=['HLG-2026-0000','x','Vieja','','','','',1,0.16,1.16,'ANA.LOPEZ@helgen.tech','u','[]','','Helgen',''];
|
|
cot.m.splice(1,0,vieja.slice());
|
|
const h2=post({funcion:'listarCotizaciones',args:[''],token:'t-bueno'}).datos;
|
|
assert.equal(h2.find(c=>c.folio==='HLG-2026-0000').vendedor,'Ana López Díaz','correo conocido (sin importar mayúsculas) = nombre');
|
|
assert.equal(h2.find(c=>c.folio==='HLG-2026-0001').vendedor,'antes@helgen.tech','correo sin nombre conocido = correo');
|
|
assert.deepEqual(cot.m[1],vieja,'la hoja no se modifica');
|
|
cot.m.splice(1,1);
|
|
// Pura: si un correo tiene nombres distintos, gana la fila más reciente (la de más abajo)
|
|
const Q=(email,n,a)=>{const r=Array(19).fill('');r[16]=email;r[17]=n;r[18]=a;return r};
|
|
assert.equal(ctx.nombresDeVendedores([Q('a@helgen.tech','Ana','Viejo'),Q('A@helgen.tech','Ana','Nuevo'),Q('b@helgen.tech','','')])['a@helgen.tech'],'Ana Nuevo');
|
|
assert.equal(ctx.nombresDeVendedores([Q('b@helgen.tech','','')])['b@helgen.tech'],undefined,'sin nombre no entra al mapa');
|
|
assert.equal(ctx.vendedorDeFila(Q('','',''),{}),'','fila sin datos de vendedor');
|
|
// Datos iniciales: usuario = correo del token
|
|
const d=post({funcion:'getDatosIniciales',args:[],token:'t-bueno'}).datos;
|
|
assert.equal(d.usuario,'ana.lopez@helgen.tech'); assert.equal(d.vendedor.nombre,'Ana');
|
|
assert.deepEqual([...d.proyectos],[]); assert.equal(pry.m[0][0],'Folio','la pestaña Proyectos se crea sola');
|
|
assert.equal(cot.m[0][0],'Folio'); assert.equal(cot.m[0][19],'proyecto_folio','Cotizaciones no se toca al crear Proyectos');
|
|
assert.deepEqual({...d.permisos},{cobranza:false,desbloqueo:false,compras:false},'sin ROL_COBRANZA ni ROL_COMPRAS nadie tiene cobranza ni compras');
|
|
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
|
|
assert(/solo la API/.test(ctx.doGet().t));
|
|
console.log('OK');
|