Carga rápida, sincronización sin recargar, requisiciones en cualquier proyecto y clave de proyecto

- bootstrap(): una sola llamada al entrar con roles y listas según el rol; las pestañas se dibujan
  al instante con los roles de la última sesión (localStorage, solo interfaz). getDatosIniciales
  queda como alias.
- CacheService por versión de colección (proyectos, contratos, cotizaciones, catálogo,
  requisiciones, oc, pagosCobranza, pagosProveedores): doPost sube la versión tras cada escritura
  (con candado); las hojas que se editan a mano se revisan por huella cada pocos minutos desde
  la consulta de fondo. Memoria por petición para propiedades y cachés.
- sincronizar(versiones, contexto): devuelve solo lo que cambió y respeta roles; la interfaz
  consulta cada 20 s con la pestaña visible, al cambiar de pestaña y al abrir un buscador de
  proyectos; si alguien está capturando, avisa en vez de repintar. Los cambios de rol se aplican
  sin recargar.
- Requisiciones, OC y pago a proveedores en proyectos Activos y Cerrados con o sin contrato
  (moneda de la aceptada o MXN); Cancelados no. Cobranza sin aceptada muestra costo de OC y
  "Sin contrato". Pagos de clientes siguen exigiendo contrato firmado.
- Clave de proyecto (columna L): folio visible PRY-AAAA-NNNN-XXX, sugerida con el nombre y
  editable al crear; la llave interna sigue siendo PRY-AAAA-NNNN. asignarClavesProyectos()
  (vista previa) y asignarClavesProyectosAplicar() asignan clave a los existentes y renombran
  su carpeta principal. Buscadores por clave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-23 13:47:04 -06:00
parent 070b45dffb
commit d56dd38e02
11 changed files with 1076 additions and 271 deletions

703
Code.js

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@ let borradas=[],creados=0,catSS;
const db={getId:()=>'db',getSheetByName:n=>dbSheets[n]||null,getSheets:()=>Object.values(dbSheets),deleteSheet:h=>{borradas.push(h.getName());delete dbSheets[h.getName()]}}; const db={getId:()=>'db',getSheetByName:n=>dbSheets[n]||null,getSheets:()=>Object.values(dbSheets),deleteSheet:h=>{borradas.push(h.getName());delete dbSheets[h.getName()]}};
const props={SPREADSHEET_ID:'db',FOLDER_ID:'f'}; const props={SPREADSHEET_ID:'db',FOLDER_ID:'f'};
const ctx={console,Date, const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
SpreadsheetApp:{flush(){},newDataValidation:()=>chain, SpreadsheetApp:{flush(){},newDataValidation:()=>chain,

View File

@ -72,14 +72,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo
const ctx={console:{...console,warn(){}},Date,JSON, const ctx={console:{...console,warn(){}},Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, Session:{getActiveUser:()=>({getEmail:()=>''})},
Logger:{log(){}}, Logger:{log(){}},
Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c},
base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')),
DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'},
UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
@ -165,7 +165,7 @@ const sinProy=cot('',50);
// --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO --- // --- Usuario sin rol (vendedora): no ve la pestaña ni recibe datos; el servidor rechaza todo con PERMISO ---
const dAna=ok('ana','getDatosIniciales'); const dAna=ok('ana','getDatosIniciales');
assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false}); assert.deepEqual({...dAna.permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:false});
assert(!/contrato|pagos|saldo/i.test(JSON.stringify(dAna)),'datos iniciales sin cobranza'); { const {versiones, ...resto} = dAna; assert(!/contrato|pagos|saldo/i.test(JSON.stringify(resto)),'datos iniciales sin cobranza'); } // versiones solo trae nombres de colecciones y números
const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]], const COB=[['listarProyectosCobranza',['']],['getCobranzaProyecto',[p1]],['marcarAceptada',[p1,cH]],['guardarEsquema',[p1,P.esquemaPorOmision()]],
['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]]; ['firmarContrato',[p1,HOY_ISO]],['desbloquearContrato',[p1,'motivo largo']],['registrarPago',[{proyectoFolio:p1}]],['cancelarPago',['PAG-'+ANIO+'-0001','motivo']]];
for(const [f,args] of COB) falla('ana',f,args,/No tienes permiso de cobranza/,'PERMISO'); for(const [f,args] of COB) falla('ana',f,args,/No tienes permiso de cobranza/,'PERMISO');
@ -371,7 +371,9 @@ assert.deepEqual([tMXN.proyectos,tMXN.conSaldo,tMXN.contratado,tMXN.cobrado,tMXN
assert.deepEqual([tUSD.proyectos,tUSD.conSaldo,tUSD.contratado,tUSD.cobrado,tUSD.porCobrar,tUSD.avance],[1,0,1000,1000,0,100],'USD aparte: nunca se suma con MXN'); assert.deepEqual([tUSD.proyectos,tUSD.conSaldo,tUSD.contratado,tUSD.cobrado,tUSD.porCobrar,tUSD.avance],[1,0,1000,1000,0,100],'USD aparte: nunca se suma con MXN');
assert.deepEqual(rs.totales.map(t=>t.moneda),['MXN','USD']); assert.deepEqual(rs.totales.map(t=>t.moneda),['MXN','USD']);
// Proyecto cancelado: no aparece ni suma // Proyecto cancelado: no aparece ni suma
db.pest.Proyectos.m[filaP3][6]='Cancelado'; db.pest.Proyectos.m[filaP3][6]='Cancelado'; // editado a mano en la hoja (la app no sube la versión)
// A los 5 min vence el caché de Proyectos: al releerla, la huella cambia, sube la versión y el resumen se recalcula
for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k];
rs=ok('tania','resumenCobranzaGeneral'); rs=ok('tania','resumenCobranzaGeneral');
assert(!rs.proyectos.some(x=>x.folio===p3)); assert.equal(rs.totales.find(t=>t.moneda==='MXN').porFirmar,0); assert(!rs.proyectos.some(x=>x.folio===p3)); assert.equal(rs.totales.find(t=>t.moneda==='MXN').porFirmar,0);
db.pest.Proyectos.m[filaP3][6]='Activo'; db.pest.Proyectos.m[filaP3][6]='Activo';

View File

@ -74,14 +74,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo
const ctx={console:{...console,warn(){}},Date,JSON, const ctx={console:{...console,warn(){}},Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, Session:{getActiveUser:()=>({getEmail:()=>''})},
Logger:{log(){}}, Logger:{log(){}},
Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c},
base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')),
DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'},
UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
@ -190,12 +190,14 @@ const PDF=Buffer.from('%PDF-1.4 cotizacion del proveedor').toString('base64');
const req=(proyectoFolio)=>ok('obrero','guardarRequisicion',{proyectoFolio,renglones:[{descripcion:'Material',cantidad:1,unidad:'pza'}], const req=(proyectoFolio)=>ok('obrero','guardarRequisicion',{proyectoFolio,renglones:[{descripcion:'Material',cantidad:1,unidad:'pza'}],
cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'rq'+(++nId)}).folio; cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'rq'+(++nId)}).folio;
const reqP4=req(p4); const reqP4=req(p4);
const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado'; const filaP4=db.pest.Proyectos.m.findIndex(r=>r[0]===p4); db.pest.Proyectos.m[filaP4][6]='Cancelado'; // a mano en la hoja
for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k]; // pasan los 5 min del caché
// --- Lista: solo Activos con contrato firmado, con su moneda y sin montos --- // --- Lista: Activos y Cerrados con o sin contrato (los Cancelados no), con su moneda y sin montos ---
const lista=ok('pedro','listarProyectosCompras',''); const lista=ok('pedro','listarProyectosCompras','');
assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca),[p2+':USD:energy',p1+':MXN:helgen']); assert.deepEqual(lista.map(p=>p.folio+':'+p.moneda+':'+p.marca+':'+p.sinContrato),
assert(!/total|subtotal|contrat/i.test(JSON.stringify(lista)),'sin montos del contrato'); [p3+':MXN:helgen:false',p2+':USD:energy:false',p1+':MXN:helgen:false'],'p3 tiene aceptada sin firmar; p4 está cancelado');
assert(!/total|subtotal|contratado|margen/i.test(JSON.stringify(lista)),'sin montos del contrato');
assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]); assert.deepEqual(ok('pedro','listarProyectosCompras','texas').map(p=>p.folio),[p2]);
// --- Vista: sin montos del contrato ni margen; el libro y la carpeta son privados --- // --- Vista: sin montos del contrato ni margen; el libro y la carpeta son privados ---
@ -235,7 +237,7 @@ assert(!/4,100|4100/.test(html),'sin precio de venta'); assert(html.includes('DO
// Idempotencia // Idempotencia
assert.equal(ok('pedro','autorizarRequisicion',REQ('0002'),oc({idSolicitud:'oc1'})).ocFolio,r.folio); assert.equal(H('OrdenesCompra').length,2); assert.equal(ok('pedro','autorizarRequisicion',REQ('0002'),oc({idSolicitud:'oc1'})).ocFolio,r.folio); assert.equal(H('OrdenesCompra').length,2);
// Rechazos que no gastan folio (la requisición sigue Solicitada) // Rechazos que no gastan folio (la requisición sigue Solicitada)
falla('obrero','guardarRequisicion',[{proyectoFolio:p3,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/); falla('obrero','guardarRequisicion',[{proyectoFolio:p4,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/está cancelado: no se piden materiales/);
falla('pedro','autorizarRequisicion',[reqP4,oc()],/está cancelado: no admite órdenes/); falla('pedro','autorizarRequisicion',[reqP4,oc()],/está cancelado: no admite órdenes/);
const rPend=req(p1); const rPend=req(p1);
falla('pedro','autorizarRequisicion',[rPend,oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/); falla('pedro','autorizarRequisicion',[rPend,oc({moneda:'USD'})],/proyecto es en MXN y la orden en USD: captura el tipo de cambio/);
@ -306,12 +308,10 @@ falla('pedro','resumenCobranzaGeneral',[],/No tienes permiso de cobranza/,'PERMI
// --- Con órdenes vigentes en MXN, la aceptada no puede cambiar a USD (tras desbloquear) --- // --- Con órdenes vigentes en MXN, la aceptada no puede cambiar a USD (tras desbloquear) ---
ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes'); ok('efrain','desbloquearContrato',p1,'Prueba de moneda con órdenes');
falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/); falla('efrain','marcarAceptada',[p1,c1usd],/órdenes de compra en MXN/);
// Sin contrato firmado: sale de la lista y no se emiten órdenes; las que tiene se siguen viendo // Sin contrato firmado (desbloqueado) sigue en la lista y admite requisiciones y órdenes en la moneda de su aceptada
assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio),[p2]); assert.deepEqual(ok('pedro','listarProyectosCompras','').map(p=>p.folio+':'+p.moneda),[p3+':MXN',p2+':USD',p1+':MXN']);
v=ok('pedro','getComprasProyecto',p1); assert.equal(v.ordenes.length,3); v=ok('pedro','getComprasProyecto',p1); assert.equal(v.ordenes.length,3);
assert.equal(ok('pedro','getRequisicion',rPend).puedeAutorizar,false); assert.equal(ok('pedro','getRequisicion',rPend).puedeAutorizar,true);
falla('pedro','autorizarRequisicion',[rPend,oc()],/contrato firmado/);
falla('obrero','guardarRequisicion',[{proyectoFolio:p1,renglones:[{descripcion:'A',cantidad:1}],cotizacion:{datos:PDF}}],/activos con contrato firmado/);
ok('efrain','firmarContrato',p1,HOY_ISO); ok('efrain','firmarContrato',p1,HOY_ISO);
// --- Bitácora, separación de datos y reparación del acceso --- // --- Bitácora, separación de datos y reparación del acceso ---
@ -327,4 +327,30 @@ assert.deepEqual(Object.keys(libros[props.COBRANZA_DB_ID].pest).sort(),['Bitacor
assert(vivos(maestra).every(x=>!/Compras/.test(x.nombre)),'nada de compras en la carpeta maestra'); assert(vivos(maestra).every(x=>!/Compras/.test(x.nombre)),'nada de compras en la carpeta maestra');
carpCom.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCompras(); carpCom.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCompras();
assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCompras repara el acceso'); assert.deepEqual([carpCom.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCompras repara el acceso');
// --- Proyecto sin cotización aceptada: requisición, orden (MXN, sin línea de negocio) y pago; Cobranza ve "Sin contrato" ---
const p5=proy('Bodega Norte','p5','Saltillo');
const cerrado=proy('Ya terminado','p6','');
db.pest.Proyectos.m[db.pest.Proyectos.m.findIndex(r=>r[0]===cerrado)][6]='Cerrado';
for (const k of Object.keys(cache)) if (k.startsWith('F_proyectos')) delete cache[k];
const rl=ok('obrero','listarProyectosRequisicion','');
assert(rl.some(p=>p.folio===p5)&&rl.some(p=>p.folio===cerrado)&&!rl.some(p=>p.folio===p4),'Activos y Cerrados sin contrato; Cancelados no');
assert.equal(ok('obrero','listarProyectosRequisicion','BON').map(p=>p.folio)[0],p5,'se busca por clave');
const r5=req(p5);
let v5=ok('pedro','getRequisicion',r5);
assert.deepEqual([v5.moneda,v5.marca,v5.sinContrato,v5.puedeAutorizar],['MXN','',true,true]);
const o5=ok('pedro','autorizarRequisicion',r5,oc({idSolicitud:'o5',proyectoFolio:p5,renglones:[{descripcion:'Cemento',cantidad:4,costo:250}]}));
const fo5=H('OrdenesCompra').find(x=>x[0]===o5.ocFolio);
assert.deepEqual([fo5[2],fo5[3],fo5[13],fo5[18],fo5[20]],[p5,'','MXN','MXN',1000],'sin contrato: MXN y sin línea de negocio');
ok('pedro','registrarPagoProveedor',{reqFolio:r5,fecha:HOY_ISO,monto:1160,monedaPago:'MXN',metodo:'Transferencia',referencia:'SPEI 5',
comprobante:{nombre:'p.pdf',datos:PDF},idSolicitud:'pg5'});
assert.equal(ok('pedro','getRequisicion',r5).requisicion.estado,'Pagada');
const vc5=ok('efrain','getCobranzaProyecto',p5);
assert.deepEqual([vc5.contrato,vc5.margen.sinContrato,vc5.margen.costos[0].moneda,vc5.margen.costos[0].monto,vc5.margen.costos[0].cantidad],
[null,true,'MXN',1000,1],'Cobranza: costo de las órdenes y "Sin contrato"');
falla('efrain','registrarPago',[{proyectoFolio:p5,fecha:HOY_ISO,monto:100,monedaPago:'MXN',metodo:'Efectivo',comprobante:{nombre:'p.pdf',datos:PDF}}],
/contrato firmado/);
// Con órdenes en MXN no se puede aceptar una cotización en USD (nunca se mezclan monedas)
const c5usd=cot(p5,100,{moneda:'USD'});
falla('efrain','marcarAceptada',[p5,c5usd],/órdenes de compra en MXN/);
console.log('OK compras'); console.log('OK compras');

View File

@ -14,11 +14,11 @@ const cfg=hoja([['Clave','Valor'],['EMPRESA_NOMBRE','Helgen Technologies'],['EMP
const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas']]); const cot=hoja([['Folio','Fecha','Cliente','Empresa','RFC','Correo','Teléfono','Subtotal','IVA','Total','Elaboró','PDF','Conceptos (JSON)','Notas']]);
const props={SPREADSHEET_ID:'x',FOLDER_ID:'y'},cache={},pdfs=[]; const props={SPREADSHEET_ID:'x',FOLDER_ID:'y'},cache={},pdfs=[];
const ctx={console,Date, const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
SpreadsheetApp:{flush(){},openById:()=>({getSheetByName:n=>n==='Config'?cfg:cot})}, SpreadsheetApp:{flush(){},openById:()=>({getSheetByName:n=>n==='Config'?cfg:cot})},
DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:b=>{pdfs.push(b);return{getUrl:()=>'u'}}})})})}, DriveApp:{getFolderById:()=>({getFoldersByName:()=>({hasNext:()=>true,next:()=>({createFile:b=>{pdfs.push(b);return{getUrl:()=>'u'}}})})})},
}; };

View File

@ -19,25 +19,33 @@ const CAT=[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA',precio:
const TC={valor:18.45,fuente:'Google Finance'}; const TC={valor:18.45,fuente:'Google Finance'};
/** App cargada con un DOM y un servidor simulados nuevos. */ /** App cargada con un DOM y un servidor simulados nuevos. */
function montar(datosIniciales){ function montar(datosIniciales,opts){
opts=opts||{};
function elemento(tag){const e={tag,value:'',textContent:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{}, function elemento(tag){const e={tag,value:'',textContent:'',className:'',title:'',src:'',placeholder:'',disabled:false,hijos:[],oyentes:{},
_clases:new Set(),porClase:{}, _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)}, 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), addEventListener:(t,f)=>(e.oyentes[t]=e.oyentes[t]||[]).push(f),
disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})), disparar:t=>(e.oyentes[t]||[]).forEach(f=>f({target:e})),
focus(){},remove(){},appendChild:h=>e.hijos.push(h), focus(){},remove(){},appendChild:h=>e.hijos.push(h),prepend:h=>e.hijos.unshift(h),contains:()=>false,
querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')), querySelector:sel=>e.porClase[sel]||(e.porClase[sel]=elemento('input')),
querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]}; querySelectorAll:sel=>sel==='tr'?e.hijos:sel==='input'?Object.values(e.porClase):[]};
// Como el DOM: innerHTML = '' quita los renglones agregados con appendChild // Como el DOM: innerHTML = '' quita los renglones agregados con appendChild
let h='';Object.defineProperty(e,'innerHTML',{get:()=>h,set:v=>{h=v;if(v==='')e.hijos.length=0}});return e} let h='';Object.defineProperty(e,'innerHTML',{get:()=>h,set:v=>{h=v;if(v==='')e.hijos.length=0}});return e}
const porId={}; const porId={};
const oyentesDoc={};
const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')), const document={getElementById:id=>porId[id]||(porId[id]=elemento('div')),
querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t)}; querySelector:()=>({src:'data:logo'}),createElement:t=>elemento(t),visibilityState:'visible',activeElement:null,
addEventListener:(t,f)=>(oyentesDoc[t]=oyentesDoc[t]||[]).push(f),dispatchEvent:ev=>(oyentesDoc[ev.type]||[]).forEach(f=>f(ev))};
document.getElementById('login').classList.add('oculto'); document.getElementById('login').classList.add('oculto');
document.getElementById('tabCob').classList.add('oculto'); // como en el HTML document.getElementById('tabCob').classList.add('oculto'); // como en el HTML
document.getElementById('tabCom').classList.add('oculto'); document.getElementById('tabCom').classList.add('oculto');
const almacen={cotizadorHelgenSesion:token}; const almacen={cotizadorHelgenSesion:token};
const llamadas=[], respuestas={getDatosIniciales:{ok:true,datos:datosIniciales}}; // bootstrap = la carga inicial; sincronizar sin cambios (mismos permisos) salvo que la prueba ponga otra respuesta
const llamadas=[], respuestas={bootstrap:{ok:true,datos:datosIniciales},
sincronizar:()=>({ok:true,datos:{versiones:{},permisos:datosIniciales.permisos,soloRequisicion:!!datosIniciales.soloRequisicion,cambios:[]}})};
const local={...(opts.local||{})}, intervalos=[];
// bootstrapPendiente: la respuesta llega cuando la prueba llama la función que recibe (para ver la página antes)
if (opts.bootstrapPendiente) respuestas.bootstrap=()=>new Promise(res=>opts.bootstrapPendiente(()=>res({ok:true,datos:datosIniciales})));
async function fetch(url,opts){const c=JSON.parse(opts.body);llamadas.push(c); 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; const r=respuestas[c.funcion];const datos=typeof r==='function'?r(c):r;
return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}} return{ok:true,json:async()=>datos||{ok:false,error:'sin respuesta simulada: '+c.funcion}}}
@ -47,11 +55,13 @@ function montar(datosIniciales){
atob:s=>Buffer.from(s,'base64').toString('binary'),crypto:{randomUUID:()=>'id-'+Math.random()}, 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]}, sessionStorage:{getItem:k=>almacen[k]??null,setItem:(k,v)=>almacen[k]=v,removeItem:k=>delete almacen[k]},
confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt}, confirm:m=>{dialogos.vistos.push(m);return dialogos.confirmar},prompt:m=>{dialogos.vistos.push(m);return dialogos.prompt},
setTimeout,clearTimeout,scrollTo(){}}; localStorage:{getItem:k=>local[k]??null,setItem:(k,v)=>local[k]=String(v),removeItem:k=>delete local[k]},
CustomEvent:class{constructor(type,o){this.type=type;this.detail=o&&o.detail}},
setTimeout,clearTimeout,setInterval:(f,ms)=>intervalos.push(f),scrollTo(){}};
ctx.window=ctx; ctx.window=ctx;
vm.createContext(ctx); vm.createContext(ctx);
vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx); vm.runInContext(apiWeb,ctx); vm.runInContext(app,ctx);
return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,run:c=>vm.runInContext(c,ctx)}; return {ctx,$:id=>document.getElementById(id),llamadas,respuestas,dialogos,local,intervalos,document,run:c=>vm.runInContext(c,ctx)};
} }
const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmediate(r))}; const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmediate(r))};
@ -188,7 +198,18 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
const nRes=B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length; const nRes=B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length;
B.run('verResumenCob()'); await esperar(); B.run('verResumenCob()'); await esperar();
assert(!B.$('cobVistaResumen').classList.contains('oculto')&&B.$('cobVistaProyecto').classList.contains('oculto')); assert(!B.$('cobVistaResumen').classList.contains('oculto')&&B.$('cobVistaProyecto').classList.contains('oculto'));
assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes+1,'al regresar se actualiza el resumen'); assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes,'al regresar no se vuelve a pedir: lo mantiene al día la sincronización');
// Sincronización: manda el contexto (resumen y proyecto abiertos) y pinta lo que cambió sin recargar
B.respuestas.sincronizar={ok:true,datos:{versiones:{pagosCobranza:7},permisos:{cobranza:true,desbloqueo:false},cambios:['pagosCobranza'],
resumen:{...RES,proyectos:RES.proyectos.slice(0,1)}}};
B.run('sincronizarAhora()'); await esperar();
const sy=B.llamadas.filter(c=>c.funcion==='sincronizar').pop();
assert.equal(sy.args[1].cobResumen,true); assert.equal(sy.args[1].cobProyecto,'PRY-2026-0001');
assert.deepEqual(B.$('resLista').innerHTML.match(/data-folio="([^"]+)"/g),['data-folio="PRY-2026-0003"'],'el resumen se actualizó solo');
assert.equal(B.run('VERS.pagosCobranza'),7,'guarda las versiones nuevas');
B.respuestas.sincronizar=()=>({ok:true,datos:{versiones:{pagosCobranza:7},permisos:{cobranza:true,desbloqueo:false},cambios:[]}});
B.run("RESUMEN=null"); B.run('verResumenCob()'); await esperar();
assert.equal(B.llamadas.filter(c=>c.funcion==='resumenCobranzaGeneral').length,nRes+1,'sin resumen cargado sí se pide');
assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML)); assert(/PRY-2026-0001 — Luis — Solar MX — Techo/.test(B.$('cobProyectosList').innerHTML)&&/Sin aceptada/.test(B.$('cobProyectosList').innerHTML));
const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'}, const cots=[{folio:'HLG-2026-0010',fecha:'20/10/2026',marca:'helgen',moneda:'MXN',subtotal:1000,total:1160,pdf:'p'},
{folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}]; {folio:'HLG-2026-0011',fecha:'20/10/2026',marca:'energy',moneda:'MXN',subtotal:2000,total:2320,pdf:'p'}];
@ -469,5 +490,67 @@ const esperar=async(n=10)=>{for(let i=0;i<n;i++) await new Promise(r=>setImmedia
assert(!S.$('tabReq').classList.contains('oculto')&&!S.$('vistaReq').classList.contains('oculto')&&S.$('vistaNueva').classList.contains('oculto')); assert(!S.$('tabReq').classList.contains('oculto')&&!S.$('vistaReq').classList.contains('oculto')&&S.$('vistaNueva').classList.contains('oculto'));
assert(S.llamadas.some(c=>c.funcion==='misRequisiciones')&&/Todavía no tienes requisiciones/.test(S.$('reqLista').innerHTML)); assert(S.llamadas.some(c=>c.funcion==='misRequisiciones')&&/Todavía no tienes requisiciones/.test(S.$('reqLista').innerHTML));
assert(!S.llamadas.some(c=>['listarCotizaciones','getCatalogo','listarProyectos'].includes(c.funcion)),'no llama nada más'); assert(!S.llamadas.some(c=>['listarCotizaciones','getCatalogo','listarProyectos'].includes(c.funcion)),'no llama nada más');
// ===== Carga rápida: pestañas al instante con los roles de la última sesión (localStorage); el bootstrap las corrige =====
let liberar;
const PRYC={...PRY,clave:'TEC',folioVisible:'PRY-2026-0001-TEC'};
const T=montar({ivaTasa:0.16,catalogo:CAT,tipoCambio:TC,clientes:[],proyectos:[PRYC],marcas:{},versiones:{proyectos:1},
permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:false},
com:{proyectos:[{...PRYC,moneda:'MXN',marca:'',sinContrato:true}],requisiciones:[]}},
{local:{'cotizadorHelgenRoles:ana@helgen.tech':JSON.stringify({permisos:{cobranza:true,compras:false,requisicion:true},soloRequisicion:false})},
bootstrapPendiente:f=>liberar=f});
await new Promise(r=>setTimeout(r,5)); // los roles guardados se aplican con setTimeout al terminar de cargar el script
assert(!T.$('tabCob').classList.contains('oculto')&&!T.$('tabReq').classList.contains('oculto')&&T.$('tabCom').classList.contains('oculto'),
'antes de responder el servidor: las pestañas de la última sesión');
liberar(); await esperar(10);
assert(T.$('tabCob').classList.contains('oculto')&&T.$('tabReq').classList.contains('oculto')&&!T.$('tabCom').classList.contains('oculto'),'el bootstrap corrige');
assert.deepEqual(JSON.parse(T.local['cotizadorHelgenRoles:ana@helgen.tech']).permisos,{cobranza:false,desbloqueo:false,compras:true,requisicion:false});
assert.deepEqual(T.llamadas.map(c=>c.funcion),['bootstrap'],'al entrar: una sola llamada');
assert(/PRY-2026-0001-TEC — Luis — Solar MX — Techo/.test(T.$('proyectosList').innerHTML),'folio con clave al cotizar');
assert(/PRY-2026-0001-TEC — Luis — Solar MX — Techo/.test(T.$('comProyectosList').innerHTML)&&/MXN · sin contrato/.test(T.$('comProyectosList').innerHTML));
T.$('proyecto').value='PRY-2026-0001-TEC — Luis — Solar MX — Techo'; T.$('proyecto').disparar('change');
assert.equal(T.run('proyectoElegido().folio'),'PRY-2026-0001','se elige por la llave interna');
assert.equal(T.intervalos.length,1,'consulta cada 20 s');
// Cambio de rol sin recargar: aparece la pestaña Requisiciones (proyectos activos y cerrados)
T.respuestas.sincronizar={ok:true,datos:{versiones:{proyectos:2},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:['proyectos'],
reqProyectos:[{folio:'PRY-2026-0002',clave:'DRO',folioVisible:'PRY-2026-0002-DRO',nombre:'DROGHEDA',estado:'Cerrado'}]}};
T.respuestas.misRequisiciones={ok:true,datos:[]}; T.respuestas.listarProyectosRequisicion={ok:true,datos:[]};
T.intervalos[0](); await esperar(10);
assert(!T.$('tabReq').classList.contains('oculto'),'pestaña nueva sin recargar');
assert(/PRY-2026-0002-DRO — DROGHEDA" label="Cerrado"/.test(T.$('reqProyectosList').innerHTML),T.$('reqProyectosList').innerHTML);
assert.equal(T.run('VERS.proyectos'),2);
assert.deepEqual(T.llamadas.filter(c=>c.funcion==='sincronizar').pop().args[0],{proyectos:1},'manda las versiones que tiene');
// Con la pestaña del navegador oculta no consulta; al volver, sí
const nSync=()=>T.llamadas.filter(c=>c.funcion==='sincronizar').length, n0=nSync();
T.document.visibilityState='hidden'; T.intervalos[0](); await esperar();
assert.equal(nSync(),n0,'pestaña oculta: no consulta');
T.document.visibilityState='visible'; T.respuestas.sincronizar=()=>({ok:true,datos:{versiones:{proyectos:2},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:[]}});
T.document.dispatchEvent({type:'visibilitychange'}); await esperar();
assert.equal(nSync(),n0+1,'al volver a la pestaña consulta');
T.$('comProyecto').disparar('focus'); await esperar();
assert.equal(nSync(),n0+2,'al abrir un buscador de proyectos consulta');
// Alguien está capturando en la vista abierta: no se repinta sola; se avisa y se actualiza con un clic
const vistaCob={proyecto:PRYC,cotizaciones:[],contrato:null,esquema:[],resumen:null,pagos:[],puedeAceptar:true,puedeFirmar:false,metodos:[],hoy:'2026-10-20',
margen:{sinContrato:true,costos:[{concepto:'Órdenes de compra',moneda:'MXN',monto:1000,cantidad:1}]}};
T.run("COB={proyecto:{folio:'PRY-2026-0001'}}"); T.$('cobVistaProyecto').disparar('input');
T.respuestas.sincronizar={ok:true,datos:{versiones:{proyectos:2,oc:3},permisos:{cobranza:false,desbloqueo:false,compras:true,requisicion:true},cambios:['oc'],cobranzaProyecto:vistaCob}};
T.run('sincronizarAhora()'); await esperar();
assert.equal(T.run('COB.proyecto.clave'),undefined,'no se repintó mientras capturaba');
const avisoSync=T.$('cobVistaProyecto').querySelector('.aviso-sync');
assert(/Otra persona cambió esta información/.test(avisoSync.innerHTML));
avisoSync.querySelector('a').onclick({preventDefault(){}});
assert(/PRY-2026-0001-TEC/.test(T.$('cobInfo').innerHTML),'con el clic se actualiza');
assert(/Costo de órdenes de compra/.test(T.$('cobMargen').innerHTML)&&/\$1,000\.00 MXN/.test(T.$('cobMargen').innerHTML)&&/Sin contrato/.test(T.$('cobMargen').innerHTML),
'sin contrato: costo de las órdenes y "Sin contrato"');
// Clave del proyecto: se sugiere con el nombre y se puede editar (3 letras A–Z)
T.$('npNombre').value='Casa del Lago'; T.$('npNombre').disparar('input');
assert.equal(T.$('npClave').value,'CAL'); assert(/PRY-\d{4}-NNNN-CAL/.test(T.$('npFolio').textContent));
T.$('npClave').value='xy1z'; T.$('npClave').disparar('input'); assert.equal(T.$('npClave').value,'XYZ');
T.$('npNombre').value='Otra cosa'; T.$('npNombre').disparar('input'); assert.equal(T.$('npClave').value,'XYZ','la editada a mano no se reemplaza');
assert.equal(T.run("claveSugerida('DROGHEDA')+claveSugerida('Planta de Agua Norte')+claveSugerida('Ñandú')"),'DROPANNAN');
T.respuestas.crearProyecto={ok:true,datos:{folio:'PRY-2026-0009',clave:'XYZ',folioVisible:'PRY-2026-0009-XYZ',nombre:'Otra cosa',cliente:'Luis',estado:'Activo',url:'u'}};
T.$('cNombre').value='Luis'; T.run('crearProyecto()'); await esperar();
assert.equal(T.llamadas.find(c=>c.funcion==='crearProyecto').args[0].clave,'XYZ');
assert(/PRY-2026-0009-XYZ/.test(T.$('aviso').innerHTML)&&/^PRY-2026-0009-XYZ — /.test(T.$('proyecto').value));
console.log('OK interfaz'); console.log('OK interfaz');
})().catch(e=>{console.error(e);process.exit(1)}); })().catch(e=>{console.error(e);process.exit(1)});

View File

@ -12,7 +12,7 @@ function hoja(m){return{m,getLastRow:()=>m.length,getMaxRows:()=>1000,setFrozenR
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}), 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}),
setValue(v){m[r-1][c-1]=v;return this}, setValue(v){m[r-1][c-1]=v;return this},
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}, 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}})}} setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this},setNumberFormat(){return this}})}}
// Drive simulado: árbol de carpetas con Papelera // Drive simulado: árbol de carpetas con Papelera
let nId=0; const carpetas={}; let nId=0; const carpetas={};
@ -37,11 +37,11 @@ const db={getSheetByName:n=>n==='Config'?cfg:n==='Cotizaciones'?cot:n==='Proyect
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT'},cache={}; const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT'},cache={};
const dv=()=>{const b={requireValueInList:(l)=>{b.lista=l;return b},setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}; const dv=()=>{const b={requireValueInList:(l)=>{b.lista=l;return b},setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b};
const ctx={console:{...console,warn(){}},Date,JSON, const ctx={console:{...console,warn(){}},Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, Session:{getActiveUser:()=>({getEmail:()=>''})},
Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'20/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, Utilities:{formatDate:(d,tz,f)=>f==='yyyy'?'2026':'20/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
SpreadsheetApp:{flush(){},newDataValidation:dv, SpreadsheetApp:{flush(){},newDataValidation:dv,
openById:id=>id==='CAT'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:db}, openById:id=>id==='CAT'?{getSheetByName:()=>hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT']]),getUrl:()=>'cat'}:db},
DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]}, DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
@ -60,6 +60,14 @@ assert.throws(()=>ctx.normalizarProyecto({nombre:'P',cliente:{}}),/cliente del p
assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Luis',empresa:'Solar MX'}}).cliente,'Luis — Solar MX'); assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Luis',empresa:'Solar MX'}}).cliente,'Luis — Solar MX');
assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Solar MX',empresa:'solar mx'}}).cliente,'Solar MX'); assert.equal(ctx.normalizarProyecto({nombre:'P',cliente:{nombre:'Solar MX',empresa:'solar mx'}}).cliente,'Solar MX');
assert.equal(ctx.nombreCarpetaProyecto('PRY-2026-0001','A/B: "C"','Techo*1?'),'PRY-2026-0001 — A B C — Techo 1'); assert.equal(ctx.nombreCarpetaProyecto('PRY-2026-0001','A/B: "C"','Techo*1?'),'PRY-2026-0001 — A B C — Techo 1');
// Clave de proyecto (3 letras): sugerida con el nombre, editable al crear, validada en el servidor
for (const [nombre,clave] of [['DROGHEDA','DRO'],['Casa del Lago','CAL'],['Planta de Agua Norte','PAN'],['Ñandú','NAN'],
['El Sol','SOL'],['Línea 3','LIN'],['4 Torres de Querétaro','TOQ'],['Planta para la Energía del Sol','PES'],['Y','XXX'],['',"XXX"],
['Yo','YOX'],['A1 B2','ABX'],['Nave Industrial','NAI'],['Proyecto de Prueba oficial','PPO']]) assert.equal(ctx.claveSugerida(nombre),clave,nombre);
assert.equal(ctx.validarClaveProyecto(' dro ','x'),'DRO'); assert.equal(ctx.validarClaveProyecto('','Casa del Lago'),'CAL','vacía = sugerida');
for (const mal of ['DR','DROG','DRÓ','DR1','D-O','ÑAN']) assert.throws(()=>ctx.validarClaveProyecto(mal,'x'),/exactamente 3 letras/,mal);
assert.deepEqual([ctx.folioVisible('PRY-2026-0002','dro'),ctx.folioVisible('PRY-2026-0002','')],['PRY-2026-0002-DRO','PRY-2026-0002']);
assert.deepEqual(['PRY-2026-0002-DRO','pry-2026-0002',' PRY-2026-0002 ','',null].map(ctx.folioInterno),['PRY-2026-0002','PRY-2026-0002','PRY-2026-0002','','']);
// El cliente_id de clientesDesdeFilas es el mismo que el del proyecto // El cliente_id de clientesDesdeFilas es el mismo que el del proyecto
const F=(n,e,rfc)=>{const r=['F','x',n,e,rfc,'','',0,0,0,'','','','','',''];return r}; const F=(n,e,rfc)=>{const r=['F','x',n,e,rfc,'','',0,0,0,'','','','','',''];return r};
assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','')])[0].id,'SIN-RFC:luis|solar mx'); assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','')])[0].id,'SIN-RFC:luis|solar mx');
@ -69,14 +77,15 @@ assert.equal(ctx.clientesDesdeFilas([F('Luis','Solar MX','LUSM800101AB1')])[0].i
const nuevo=(extra)=>({nombre:'Techo solar planta',ubicacion:'Querétaro',cliente:{nombre:'Luis',empresa:'Solar MX',rfc:'LUSM800101AB1'},...extra}); const nuevo=(extra)=>({nombre:'Techo solar planta',ubicacion:'Querétaro',cliente:{nombre:'Luis',empresa:'Solar MX',rfc:'LUSM800101AB1'},...extra});
const p1=ctx.crearProyecto(nuevo({idSolicitud:'p1',vendedor:{email:'impostor@helgen.tech'}})); const p1=ctx.crearProyecto(nuevo({idSolicitud:'p1',vendedor:{email:'impostor@helgen.tech'}}));
assert.equal(p1.folio,'PRY-2026-0001'); assert.equal(p1.estado,'Activo'); assert.equal(p1.clienteId,'LUSM800101AB1'); assert.equal(p1.folio,'PRY-2026-0001'); assert.equal(p1.estado,'Activo'); assert.equal(p1.clienteId,'LUSM800101AB1');
assert.equal(pry.m[0].join(','),'Folio,Fecha,Nombre,Cliente,cliente_id,Ubicación,Estado,carpeta_id,vendedor_email,vendedor_nombre,vendedor_apellido'); assert.equal(pry.m[0].join(','),'Folio,Fecha,Nombre,Cliente,cliente_id,Ubicación,Estado,carpeta_id,vendedor_email,vendedor_nombre,vendedor_apellido,clave');
const f1=pry.m[1]; const f1=pry.m[1];
assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']); assert.deepEqual([f1[0],f1[2],f1[3],f1[4],f1[5],f1[6]],['PRY-2026-0001','Techo solar planta','Luis — Solar MX','LUSM800101AB1','Querétaro','Activo']);
assert.deepEqual(f1.slice(8),['ana@helgen.tech','Ana','López'],'vendedor del servidor'); assert.deepEqual(f1.slice(8,11),['ana@helgen.tech','Ana','López'],'vendedor del servidor');
assert.equal(f1[11],'TSP','clave sugerida con el nombre (Techo solar planta) en la columna L');
assert(!JSON.stringify(pry.m).includes('impostor')); assert(!JSON.stringify(pry.m).includes('impostor'));
// Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ solo con Cotizaciones (Pagos y Órdenes de compra ya no: van en cobranza y compras) // Carpeta: Cotizador Helgen/Proyectos/2026/<folio> — <cliente> — <nombre>/ solo con Cotizaciones (Pagos y Órdenes de compra ya no: van en cobranza y compras)
const c1=carpetas[f1[7]]; const c1=carpetas[f1[7]];
assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001 — Luis — Solar MX — Techo solar planta'); assert.equal(ruta(c1),'Cotizador Helgen/Proyectos/2026/PRY-2026-0001-TSP — Luis — Solar MX — Techo solar planta');
assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones']); assert.deepEqual(c1.hijos.map(h=>h.nombre),['Cotizaciones']);
assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1'); assert.equal(props.PROYECTOS_FOLDER_ID,c1.padre.padre.id); assert.equal(props.CONSECUTIVO_PROY_2026,'1');
assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia'); assert.equal(props.CONSECUTIVO_2026,undefined,'el contador de cotizaciones no cambia');
@ -113,7 +122,7 @@ ctx.guardarCotizacion({...base,idSolicitud:'c2'});
assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],''); assert.equal(vivos(pdfRaiz).find(h=>h.nombre==='2026').archivos.length,1); assert.equal(cot.m[3][19],'');
// Historial // Historial
const h=ctx.listarCotizaciones(''); 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-TSP','Sin proyecto'],'el Historial muestra el folio con clave');
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'}), assert.throws(()=>ctx.guardarCotizacion({...base,proyectoFolio:'PRY-2026-0099',idSolicitud:'c3'}),

View File

@ -77,14 +77,14 @@ const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMont
.replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds())); .replace('HH',pad(d.getHours())).replace('mm',pad(d.getMinutes())).replace('ss',pad(d.getSeconds()));
const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo const firmados=b=>[...b].map(x=>x>127?x-256:x); // Apps Script entrega bytes con signo
const ctx={console:{...console,warn(){}},Date,JSON, const ctx={console:{...console,warn(){}},Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, Session:{getActiveUser:()=>({getEmail:()=>''})},
Logger:{log(){}}, Logger:{log(){}},
Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c},
base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')),
DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'},
UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t];
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
@ -185,10 +185,15 @@ for(const [f,args] of [['getClientes',[]],['listarCotizaciones',['']],['getCatal
} }
assert.equal(ok('obrero','listarCotizaciones','').length>0,true,'ROL_REQUISICION sin SOLO usa el cotizador como siempre'); assert.equal(ok('obrero','listarCotizaciones','').length>0,true,'ROL_REQUISICION sin SOLO usa el cotizador como siempre');
// --- Proyectos para pedir: Activos con contrato firmado; solo folio y nombre --- // --- Proyectos para pedir: Activos y Cerrados, con o sin contrato; solo folio, clave y nombre ---
const lp=ok('luis','listarProyectosRequisicion',''); const lp=ok('luis','listarProyectosRequisicion','');
assert.deepEqual(lp.map(p=>({...p})),[{folio:p2,nombre:'Planta Texas'},{folio:p1,nombre:'Techo solar'}]); assert.deepEqual(lp.map(p=>({...p})),[
{folio:p3,clave:'SIF',folioVisible:p3+'-SIF',nombre:'Sin firmar',estado:'Activo'},
{folio:p2,clave:'PLT',folioVisible:p2+'-PLT',nombre:'Planta Texas',estado:'Activo'},
{folio:p1,clave:'TES',folioVisible:p1+'-TES',nombre:'Techo solar',estado:'Activo'}]);
assert.deepEqual(ok('luis','listarProyectosRequisicion','texas').map(p=>p.folio),[p2]); assert.deepEqual(ok('luis','listarProyectosRequisicion','texas').map(p=>p.folio),[p2]);
assert.deepEqual(ok('luis','listarProyectosRequisicion','plt').map(p=>p.folio),[p2],'por clave');
assert.deepEqual(ok('luis','listarProyectosRequisicion',p2+'-PLT').map(p=>p.folio),[p2],'por folio con clave');
assert.deepEqual(ok('luis','listarProyectosRequisicion','Cliente Secreto'),[],'no busca por cliente'); assert.deepEqual(ok('luis','listarProyectosRequisicion','Cliente Secreto'),[],'no busca por cliente');
// --- Crear: cotización obligatoria (PDF, JPG, PNG o HEIC, máx. 10 MB); folio REQ; carpeta privada; Solicitada --- // --- Crear: cotización obligatoria (PDF, JPG, PNG o HEIC, máx. 10 MB); folio REQ; carpeta privada; Solicitada ---
@ -198,7 +203,10 @@ falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:null})],/Adjunta
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.from('hola mundo txt').toString('base64')}})],/La cotización debe ser PDF o imagen/); falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.from('hola mundo txt').toString('base64')}})],/La cotización debe ser PDF o imagen/);
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.concat([Buffer.from('%PDF-'),Buffer.alloc(10*1024*1024)]).toString('base64')}})],/pesa más de 10 MB/); falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:{datos:Buffer.concat([Buffer.from('%PDF-'),Buffer.alloc(10*1024*1024)]).toString('base64')}})],/pesa más de 10 MB/);
falla('obrero','guardarRequisicion',[nueva('obrero',{renglones:[{descripcion:'A',cantidad:0}]})],/mayor a 0/); falla('obrero','guardarRequisicion',[nueva('obrero',{renglones:[{descripcion:'A',cantidad:0}]})],/mayor a 0/);
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/activos con contrato firmado/); const filaP3=db.pest.Proyectos.m.findIndex(x=>x[0]===p3);
db.pest.Proyectos.m[filaP3][6]='Cancelado';
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/está cancelado: no se piden materiales/);
db.pest.Proyectos.m[filaP3][6]='Activo';
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/); falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:'PRY-2000-0001'})],/No existe el proyecto/);
assert.equal(props['CONSECUTIVO_REQ_'+ANIO],undefined,'los rechazos no gastan folio'); assert.equal(props['CONSECUTIVO_REQ_'+ANIO],undefined,'los rechazos no gastan folio');
let r=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'})); let r=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'}));
@ -376,4 +384,64 @@ for(const a of ['Solicitar requisición','Devolver requisición','Reenviar requi
assert.equal(H('Bitacora').slice(1).find(b=>b[2]==='Cancelar pago a proveedor')[5],'Se capturó con el tipo de cambio equivocado'); assert.equal(H('Bitacora').slice(1).find(b=>b[2]==='Cancelar pago a proveedor')[5],'Se capturó con el tipo de cambio equivocado');
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de requisiciones en la Base de datos'); assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos'],'nada de requisiciones en la Base de datos');
assert(!JSON.stringify(db.pest).includes('REQ-')&&!JSON.stringify(db.pest).includes('PGP-')); assert(!JSON.stringify(db.pest).includes('REQ-')&&!JSON.stringify(db.pest).includes('PGP-'));
// ================= Carga inicial (bootstrap) y sincronización sin recargar =================
// bootstrap: una sola llamada con los roles y las listas que necesita cada rol
const bE=ok('efrain','bootstrap'), bP=ok('pedro','bootstrap'), bA=ok('ana','bootstrap'), bL=ok('luis','bootstrap');
assert.deepEqual([!!bE.cob,!!bE.com,!!bE.req],[true,true,false],'efrain: cobranza y compras');
assert(Array.isArray(bE.cob.proyectos)&&Array.isArray(bE.com.proyectos));
// Las listas pesadas solo van si ya están en caché (con el caché vacío, la interfaz las pide al abrir la pestaña)
assert.equal(bE.com.requisiciones,null,'en frío: sin la lista de requisiciones');
ok('efrain','listarRequisiciones','','');
assert(Array.isArray(ok('efrain','bootstrap').com.requisiciones),'ya en caché: viene en el bootstrap');
assert.deepEqual([!!bP.cob,!!bP.com,!!bP.req],[false,true,true],'pedro: compras y requisiciones');
assert.deepEqual([!!bA.cob,!!bA.com,!!bA.req,bA.proyectos.length>0,bA.catalogo[0].precio],[false,false,false,true,4100],'ana: solo cotizar');
assert.deepEqual([bL.soloRequisicion,!!bL.req,!!bL.com,!!bL.cob,bL.clientes.length,bL.proyectos.length,bL.tipoCambio],[true,true,false,false,0,0,null]);
assert(!/Cliente Secreto|Empresa Oculta|4100|98765/.test(JSON.stringify(bL)),'SOLO_REQUISICION: sin clientes, precios ni montos');
assert.deepEqual(Object.keys(bE.versiones).sort(),['catalogo','contratos','cotizaciones','oc','pagosCobranza','pagosProveedores','proyectos','requisiciones']);
// Sin cambios: solo versiones y permisos (ninguna lista)
let sP=ok('pedro','sincronizar',bP.versiones,{});
assert.deepEqual([sP.cambios.length,'comRequisiciones' in sP,'reqProyectos' in sP,sP.permisos.compras],[0,false,false,true]);
// Ana crea un proyecto: los demás lo ven en su siguiente consulta, cada quien en sus listas
const p9=proy('Nave Industrial','p9','');
sP=ok('pedro','sincronizar',bP.versiones,{});
assert.deepEqual([...sP.cambios],['proyectos']);
assert(sP.comProyectos.some(p=>p.folio===p9&&p.folioVisible===p9+'-NAI'&&p.sinContrato),'compras: el proyecto nuevo, sin contrato');
assert(sP.reqProyectos.some(p=>p.folio===p9),'requisiciones: el proyecto nuevo');
assert(!('cobProyectos' in sP)&&!('proyectos' in sP&&sP.proyectos==null),'pedro no recibe cobranza');
const sE=ok('efrain','sincronizar',bE.versiones,{cobResumen:true});
assert(sE.cobProyectos.some(p=>p.folio===p9)&&sE.comProyectos.some(p=>p.folio===p9));
const sA=ok('ana','sincronizar',bA.versiones,{});
assert(sA.proyectos.some(p=>p.folio===p9)&&!('comProyectos' in sA)&&!('misReq' in sA)&&!('cobProyectos' in sA));
const sL=ok('luis','sincronizar',bL.versiones,{req:REQ('0001'),cobProyecto:p1,comProyecto:p1});
assert(sL.reqProyectos.some(p=>p.folio===p9)&&!('proyectos' in sL)&&!('comRequisiciones' in sL)&&!('requisicion' in sL)&&!('cobranzaProyecto' in sL),
'SOLO_REQUISICION: aunque pida otras vistas, solo recibe lo suyo');
assert(!/Cliente Secreto|Empresa Oculta/.test(JSON.stringify(sL)));
// Una requisición nueva: compras recibe su lista y la requisición abierta; el solicitante solo las suyas
const vAntes=ok('pedro','sincronizar',{},{}).versiones;
const rq9=ok('obrero','guardarRequisicion',{proyectoFolio:p9+'-NAI',renglones:[{descripcion:'Lámina',cantidad:3}],
cotizacion:{nombre:'c.pdf',datos:PDF},idSolicitud:'rq9'}).folio;
assert.equal(H('Requisiciones').find(x=>x[0]===rq9)[2],p9,'con el folio visible se guarda la llave interna');
const sP2=ok('pedro','sincronizar',vAntes,{req:rq9,rcEstado:'Solicitada'});
assert.deepEqual([...sP2.cambios],['requisiciones']);
assert(sP2.comRequisiciones.some(r=>r.folio===rq9&&r.proyectoVisible===p9+'-NAI')&&sP2.comRequisiciones.every(r=>r.estado==='Solicitada'));
assert.equal(sP2.requisicion.requisicion.folio,rq9);
const sO=ok('obrero','sincronizar',vAntes,{});
assert(sO.misReq.some(r=>r.folio===rq9)&&!('comRequisiciones' in sO));
assert(!ok('rosa','sincronizar',vAntes,{}).misReq.some(r=>r.folio===rq9),'rosa no ve la requisición de obrero');
// Las listas salen del caché hasta que algo cambia (y la escritura sube la versión)
assert(Object.keys(cache).some(k=>k.startsWith('K_reqs_')),'lista de requisiciones en caché');
// Cambio a mano en la hoja (Estado): la carga inicial no relee la hoja; la consulta de fondo sí, cada 5 min
const fila9=db.pest.Proyectos.m.findIndex(x=>x[0]===p9); db.pest.Proyectos.m[fila9][6]='Cancelado';
const vMan=ok('pedro','sincronizar',{},{}).versiones;
assert(ok('pedro','bootstrap').com.proyectos.some(p=>p.folio===p9),'antes de la revisión sigue el dato en caché');
delete cache['R_proyectos']; // pasan los 5 min de la revisión
const sMan=ok('pedro','sincronizar',vMan,{});
assert.deepEqual([...sMan.cambios],['proyectos'],'la huella cambió: sube la versión');
assert(!sMan.comProyectos.some(p=>p.folio===p9)&&!sMan.reqProyectos.some(p=>p.folio===p9),'cancelado a mano: sale de las listas');
assert.deepEqual([...ok('pedro','sincronizar',sMan.versiones,{}).cambios],[],'una vez avisado, no se repite');
// Cambio de rol sin recargar la página: la siguiente consulta trae los permisos nuevos
props.ROL_COMPRAS+=', tania@helgen.tech';
assert.equal(ok('tania','sincronizar',vAntes,{}).permisos.compras,true);
falla('intruso','sincronizar',[{},{}],/@helgen.tech/,'SESION');
console.log('OK requisiciones'); console.log('OK requisiciones');

View File

@ -24,11 +24,11 @@ const libros={db:libro('db',[cot,cfg]),cat:libro('cat',[cat])}; let nuevo=0;
const props={SPREADSHEET_ID:'db',FOLDER_ID:'f',CATALOGO_ID:'cat'},cache={}; const props={SPREADSHEET_ID:'db',FOLDER_ID:'f',CATALOGO_ID:'cat'},cache={};
const archivos={'SAT_c_ClaveProdServ.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveProdServ.csv','utf8'),'SAT_c_ClaveUnidad.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveUnidad.csv','utf8')}; const archivos={'SAT_c_ClaveProdServ.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveProdServ.csv','utf8'),'SAT_c_ClaveUnidad.csv':fs.readFileSync(csvDir+'/SAT_c_ClaveUnidad.csv','utf8')};
const ctx={console,Date, const ctx={console,Date,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})}, Session:{getActiveUser:()=>({getEmail:()=>'efrain@helgen.tech'})},
Utilities:{parseCsv,formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:()=>''}, Utilities:{parseCsv,formatDate:(d,tz,f)=>f==='yyyy'?'2026':'15/10/2026',newBlob:h=>({getAs:()=>({setName:n=>({name:n,html:h})})}),base64Encode:b=>Buffer.from(b).toString('base64'),base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
SpreadsheetApp:{flush(){},newDataValidation:()=>chain,create:n=>{nuevo++;const l=libro('sat',[hoja('Hoja 1',[])]);libros.sat=l;return l},openById:id=>libros[id]}, SpreadsheetApp:{flush(){},newDataValidation:()=>chain,create:n=>{nuevo++;const l=libro('sat',[hoja('Hoja 1',[])]);libros.sat=l;return l},openById:id=>libros[id]},
DriveApp:{getFilesByName:n=>{let d=false;return{hasNext:()=>!d&&!!archivos[n],next:()=>{d=true;return{isTrashed:()=>false,getParents:()=>({next:()=>({})}),getBlob:()=>({getDataAsString:()=>archivos[n]})}}}}, DriveApp:{getFilesByName:n=>{let d=false;return{hasNext:()=>!d&&!!archivos[n],next:()=>{d=true;return{isTrashed:()=>false,getParents:()=>({next:()=>({})}),getBlob:()=>({getDataAsString:()=>archivos[n]})}}}},
getFileById:()=>({moveTo(){},getParents:()=>({hasNext:()=>false})}), getFileById:()=>({moveTo(){},getParents:()=>({hasNext:()=>false})}),

View File

@ -23,13 +23,13 @@ const tokens={'t-bueno':BUENO,'t-gmail':{...BUENO,email:'ana@gmail.com',hd:undef
't-otro-dominio':{...BUENO,email:'x@otra.com',hd:'otra.com'}}; 't-otro-dominio':{...BUENO,email:'x@otra.com',hd:'otra.com'}};
let fetches=0; let fetches=0;
const ctx={console,Date,JSON, const ctx={console,Date,JSON,
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})}, PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v,getProperties:()=>Object.assign({},props),setProperties:o=>Object.assign(props,o)})},
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})}, CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v,getAll:ks=>Object.fromEntries(ks.filter(k=>k in cache).map(k=>[k,cache[k]])),putAll:o=>Object.assign(cache,o)})},
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})}, LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
Session:{getActiveUser:()=>({getEmail:()=>''})}, // despliegue anónimo: Google no da el correo 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})})}), 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'}, base64Encode:b=>Buffer.from(b).toString('base64'),DigestAlgorithm:{SHA_256:'sha256',MD5:'md5'},base64EncodeWebSafe:b=>Buffer.from(b).toString('base64url'),
computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, computeDigest:(a,s)=>[...require('crypto').createHash(a||'sha256').update(s).digest()]},
UrlFetchApp:{fetch:url=>{fetches++;const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; 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'})}}}, return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})}, ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})},

View File

@ -165,7 +165,7 @@
<div class="proy"> <div class="proy">
<div class="proy-fila"> <div class="proy-fila">
<div><label>Proyecto (opcional)</label> <div><label>Proyecto (opcional)</label>
<input id="proyecto" list="proyectosList" autocomplete="off" placeholder="Sin proyecto · busca por folio, cliente o nombre"> <input id="proyecto" list="proyectosList" autocomplete="off" placeholder="Sin proyecto · busca por folio, clave, cliente o nombre">
<datalist id="proyectosList"></datalist></div> <datalist id="proyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="abrirNuevoProyecto()">+ Nuevo proyecto</button> <button type="button" class="btn btn-sec" onclick="abrirNuevoProyecto()">+ Nuevo proyecto</button>
</div> </div>
@ -174,6 +174,8 @@
<div class="sat-titulo">Nuevo proyecto · cliente: <span id="npCliente"></span></div> <div class="sat-titulo">Nuevo proyecto · cliente: <span id="npCliente"></span></div>
<div class="grid"> <div class="grid">
<div><label>Nombre del proyecto *</label><input id="npNombre" maxlength="80" placeholder="Ej. Techo solar planta 2"></div> <div><label>Nombre del proyecto *</label><input id="npNombre" maxlength="80" placeholder="Ej. Techo solar planta 2"></div>
<div><label>Clave (3 letras) *</label><input id="npClave" maxlength="3" autocomplete="off" style="text-transform:uppercase" placeholder="Se sugiere con el nombre">
<div id="npFolio" class="nota">El folio será PRY-AAAA-NNNN-XXX. La clave ya no se cambia después.</div></div>
<div><label>Ubicación</label><input id="npUbicacion" maxlength="200" placeholder="Ciudad, dirección u obra"></div> <div><label>Ubicación</label><input id="npUbicacion" maxlength="200" placeholder="Ciudad, dirección u obra"></div>
</div> </div>
<div class="acciones"> <div class="acciones">
@ -281,7 +283,7 @@
<h2>1 · Resumen de cobranza</h2> <h2>1 · Resumen de cobranza</h2>
<!-- Todos los proyectos (también sin cotización aceptada): desde aquí se marca la primera aceptada --> <!-- Todos los proyectos (también sin cotización aceptada): desde aquí se marca la primera aceptada -->
<div style="margin-bottom:12px"><label>Abrir un proyecto (todos, también sin cotización aceptada)</label> <div style="margin-bottom:12px"><label>Abrir un proyecto (todos, también sin cotización aceptada)</label>
<input id="resProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre"></div> <input id="resProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, clave, cliente o nombre"></div>
<div id="resTotales" class="nota">Cargando…</div> <div id="resTotales" class="nota">Cargando…</div>
<div class="acciones"> <div class="acciones">
<input id="resBuscar" placeholder="Buscar por folio, cliente o nombre…" oninput="pintarResumenCob()" style="flex:1; min-width:220px"> <input id="resBuscar" placeholder="Buscar por folio, cliente o nombre…" oninput="pintarResumenCob()" style="flex:1; min-width:220px">
@ -318,7 +320,7 @@
<h2>1 · Proyecto y contrato</h2> <h2>1 · Proyecto y contrato</h2>
<div class="proy-fila"> <div class="proy-fila">
<div><label>Proyecto</label> <div><label>Proyecto</label>
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre"> <input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, clave, cliente o nombre">
<datalist id="cobProyectosList"></datalist></div> <datalist id="cobProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="verResumenCob()">← Resumen</button> <button type="button" class="btn btn-sec" onclick="verResumenCob()">← Resumen</button>
</div> </div>
@ -380,8 +382,8 @@
<h2 id="reqTitulo">1 · Nueva requisición</h2> <h2 id="reqTitulo">1 · Nueva requisición</h2>
<div id="reqEditando" class="nota oculto" style="margin-bottom:10px"></div> <div id="reqEditando" class="nota oculto" style="margin-bottom:10px"></div>
<div class="proy-fila"> <div class="proy-fila">
<div><label>Proyecto (activo y con contrato firmado) *</label> <div><label>Proyecto (activo o cerrado) *</label>
<input id="reqProyecto" list="reqProyectosList" autocomplete="off" placeholder="Busca por folio o nombre del proyecto"> <input id="reqProyecto" list="reqProyectosList" autocomplete="off" placeholder="Busca por folio, clave o nombre del proyecto">
<datalist id="reqProyectosList"></datalist></div> <datalist id="reqProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosReq()">Actualizar lista</button> <button type="button" class="btn btn-sec" onclick="cargarProyectosReq()">Actualizar lista</button>
</div> </div>
@ -426,15 +428,15 @@
</div> </div>
<table style="margin-top:14px"> <table style="margin-top:14px">
<thead><tr><th>Folio</th><th>Fecha</th><th>Proyecto</th><th>Solicitante</th><th class="num">Renglones</th><th>Estado</th><th>Orden</th></tr></thead> <thead><tr><th>Folio</th><th>Fecha</th><th>Proyecto</th><th>Solicitante</th><th class="num">Renglones</th><th>Estado</th><th>Orden</th></tr></thead>
<tbody id="rcLista"></tbody> <tbody id="rcLista"><tr><td colspan="7" class="nota">Cargando…</td></tr></tbody>
</table> </table>
<div class="nota" style="margin-top:8px">Toda orden de compra sale de una requisición: ábrela para autorizarla, pagarla o cerrarla.</div> <div class="nota" style="margin-top:8px">Toda orden de compra sale de una requisición: ábrela para autorizarla, pagarla o cerrarla.</div>
</div> </div>
<div class="modulo"> <div class="modulo" id="comModOrdenes">
<h2>2 · Órdenes por proyecto</h2> <h2>2 · Órdenes por proyecto</h2>
<div class="proy-fila"> <div class="proy-fila">
<div><label>Proyecto (activo y con contrato firmado)</label> <div><label>Proyecto (activo o cerrado)</label>
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre"> <input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, clave, cliente o nombre">
<datalist id="comProyectosList"></datalist></div> <datalist id="comProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button> <button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button>
</div> </div>
@ -578,7 +580,7 @@
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
// ---- Sesión ---- // ---- Sesión ----
let token = null, expira = 0, gisListo = false; let token = null, expira = 0, gisListo = false, correo = '';
const pendientes = []; // llamadas que esperan a que se inicie sesión const pendientes = []; // llamadas que esperan a que se inicie sesión
// Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token // Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token
@ -598,13 +600,15 @@
function guardarSesion(t) { function guardarSesion(t) {
const datos = leerToken(t); const datos = leerToken(t);
token = t; expira = Number(datos.exp) || 0; token = t; expira = Number(datos.exp) || 0; correo = String(datos.email || '').toLowerCase();
try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {} try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {}
pintarUsuario(datos); pintarUsuario(datos);
// La interfaz dibuja al instante las pestañas de la última sesión de este correo (solo interfaz)
document.dispatchEvent(new CustomEvent('sesion-lista', { detail: correo }));
} }
function olvidarSesion() { function olvidarSesion() {
token = null; expira = 0; token = null; expira = 0; correo = '';
try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {} try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {}
$('usuario').classList.add('oculto'); $('usuario').classList.add('oculto');
} }
@ -685,6 +689,9 @@
const script = { run: corredor(null, null) }; const script = { run: corredor(null, null) };
window.google = window.google || {}; window.google = window.google || {};
google.script = script; google.script = script;
// Para la sincronización: no consultar sin sesión (si no, las consultas se forman en cola y abren el inicio de sesión)
window.sesionVigente = vigente;
window.correoSesion = () => correo;
// ---- Google Identity Services (se carga con el <script> de accounts.google.com, abajo) ---- // ---- Google Identity Services (se carga con el <script> de accounts.google.com, abajo) ----
window.iniciarGis = function () { window.iniciarGis = function () {
@ -721,7 +728,7 @@
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script> <script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
<script> <script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {}; let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {}, SOLO_REQ = false;
let TC = { valor: null, fuente: '' }, MONEDA_ANT = 'MXN'; // tipo de cambio del catálogo (pestaña "Tipo de cambio") let TC = { valor: null, fuente: '' }, MONEDA_ANT = 'MXN'; // tipo de cambio del catálogo (pestaña "Tipo de cambio")
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro. // Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2)); const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
@ -737,27 +744,158 @@
const monedaActual = () => $('moneda').value === 'USD' ? 'USD' : 'MXN'; const monedaActual = () => $('moneda').value === 'USD' ? 'USD' : 'MXN';
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ---- // ---- Arranque: una sola llamada (bootstrap) ----
google.script.run.withSuccessHandler(d => { // Las pestañas se dibujan al instante con los roles de la última sesión de este correo (localStorage) y se corrigen al
IVA = d.ivaTasa; CATALOGO = d.catalogo || []; TC = d.tipoCambio || TC; // llegar el bootstrap. Es solo interfaz: el servidor vuelve a revisar los permisos en cada llamada.
PERMISOS = d.permisos || {}; let VERS = {}, BOOT = false, TAB = 'nueva';
$('tabReq').classList.toggle('oculto', !PERMISOS.requisicion); const CARGADO = { reqProy: false, misReq: false, comProy: false, comReqs: false, cobProy: false };
pintarCatalogoReq(); const CLAVE_ROLES = 'cotizadorHelgenRoles:';
if (d.soloRequisicion) { function leerRolesGuardados(correo) {
// Cuenta que solo pide materiales: sin cotizaciones, historial ni catálogo (el servidor se los niega) try { return correo ? JSON.parse(localStorage.getItem(CLAVE_ROLES + correo) || 'null') : null; } catch (e) { return null; }
['tabNueva', 'tabHist', 'tabCat', 'tabCob', 'tabCom'].forEach(id => $(id).classList.add('oculto'));
verTab('req');
return;
} }
function guardarRoles(permisos, solo) {
const correo = window.correoSesion ? correoSesion() : '';
try { if (correo) localStorage.setItem(CLAVE_ROLES + correo, JSON.stringify({ permisos: permisos, soloRequisicion: !!solo })); } catch (e) {}
}
/** Muestra u oculta las pestañas según los permisos; si la visible ya no corresponde, va a la primera permitida. */
function aplicarPermisos(permisos, solo) {
PERMISOS = permisos || {}; SOLO_REQ = !!solo;
['tabNueva', 'tabHist', 'tabCat'].forEach(id => $(id).classList.toggle('oculto', SOLO_REQ));
$('tabReq').classList.toggle('oculto', !PERMISOS.requisicion);
$('tabCob').classList.toggle('oculto', SOLO_REQ || !PERMISOS.cobranza);
$('tabCom').classList.toggle('oculto', SOLO_REQ || !PERMISOS.compras);
const boton = { nueva: 'tabNueva', hist: 'tabHist', cat: 'tabCat', req: 'tabReq', cob: 'tabCob', com: 'tabCom' }[TAB];
if ($(boton).classList.contains('oculto')) verTab(SOLO_REQ ? 'req' : 'nueva');
}
function rolesDeUltimaSesion(correo) {
if (BOOT) return;
const r = leerRolesGuardados(correo);
if (r) aplicarPermisos(r.permisos, r.soloRequisicion);
}
document.addEventListener('sesion-lista', e => rolesDeUltimaSesion(e.detail));
// Al terminar de cargar este script (verTab usa variables que se declaran más abajo)
setTimeout(() => { if (window.correoSesion && correoSesion()) rolesDeUltimaSesion(correoSesion()); }, 0);
google.script.run.withSuccessHandler(recibirBootstrap).withFailureHandler(mostrarError).bootstrap();
function recibirBootstrap(d) {
BOOT = true;
VERS = d.versiones || {};
IVA = d.ivaTasa; CATALOGO = d.catalogo || []; TC = d.tipoCambio || TC;
aplicarPermisos(d.permisos, d.soloRequisicion);
guardarRoles(d.permisos, d.soloRequisicion);
pintarCatalogoReq();
if (d.req) {
if (d.req.proyectos) recibirProyectosReq(d.req.proyectos);
if (d.req.mias) pintarMisReq(d.req.mias);
}
if (d.com) {
if (d.com.proyectos) recibirProyectosCom(d.com.proyectos);
if (d.com.requisiciones) { CARGADO.comReqs = true; pintarListaReqCom(d.com.requisiciones); }
}
if (d.cob) {
if (d.cob.proyectos) recibirProyectosCob(d.cob.proyectos);
if (d.cob.resumen) { RESUMEN = d.cob.resumen; pintarResumenCob(); }
}
if ((d.errores || []).length) console.warn('Carga inicial incompleta: ' + d.errores.join(' · '));
if (!d.soloRequisicion) {
recibirClientes(d.clientes); recibirClientes(d.clientes);
recibirProyectos(d.proyectos); recibirProyectos(d.proyectos);
// Las pestañas Cobranza, Compras y Requisiciones solo se muestran con permiso; el servidor lo revisa en cada llamada
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)'; $('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists(); pintarDatalists();
agregarFila(); if (!$('filas').querySelectorAll('tr').length) agregarFila();
}).withFailureHandler(mostrarError).getDatosIniciales(); }
verTab(TAB, true); // completa lo que falte de la pestaña visible (si el usuario ya la abrió mientras cargaba)
iniciarSincronizacion();
}
// ---- Sincronización sin recargar ----
// El servidor guarda una versión por colección (proyectos, requisiciones, órdenes, pagos…) que sube en cada escritura.
// Cada 20 s con la pestaña del navegador visible, al cambiar de pestaña y al abrir un buscador de proyectos, la interfaz
// manda sus versiones y recibe solo lo que cambió (el servidor decide qué puede ver cada rol).
const CADA_SYNC = 20000;
let SYNC_EN_CURSO = false, SYNC_OTRA = false, SYNC_INICIADA = false;
function contextoSync() {
return { cobResumen: !!RESUMEN, cobProyecto: COB ? COB.proyecto.folio : '', comProyecto: COM ? COM.proyecto.folio : '',
req: REQC ? REQC.requisicion.folio : '', rcBuscar: COM_FILTRO.buscar, rcEstado: COM_FILTRO.estado };
}
function sincronizarAhora() {
if (!BOOT || !(window.sesionVigente && sesionVigente())) return; // sin sesión no se forman consultas en cola
if (SYNC_EN_CURSO) { SYNC_OTRA = true; return; }
SYNC_EN_CURSO = true;
const fin = () => { SYNC_EN_CURSO = false; if (SYNC_OTRA) { SYNC_OTRA = false; sincronizarAhora(); } };
google.script.run.withSuccessHandler(r => { try { aplicarSync(r); } finally { fin(); } })
.withFailureHandler(err => { fin(); console.warn('Sincronización: ' + (err && err.message ? err.message : err)); })
.sincronizar(VERS, contextoSync());
}
function iniciarSincronizacion() {
if (SYNC_INICIADA) return;
SYNC_INICIADA = true;
setInterval(() => { if (document.visibilityState === 'visible') sincronizarAhora(); }, CADA_SYNC);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') sincronizarAhora(); });
['proyecto', 'resProyecto', 'cobProyecto', 'comProyecto', 'reqProyecto'].forEach(id => $(id).addEventListener('focus', sincronizarAhora));
}
function aplicarSync(r) {
if (!r) return;
const cambioRol = JSON.stringify(r.permisos) !== JSON.stringify(PERMISOS) || !!r.soloRequisicion !== SOLO_REQ;
if (cambioRol) {
// Cambió un rol (Script Properties): las pestañas se ajustan sin recargar la página
aplicarPermisos(r.permisos, r.soloRequisicion);
guardarRoles(r.permisos, r.soloRequisicion);
}
VERS = r.versiones || VERS;
if (r.catalogo) {
CATALOGO = r.catalogo; if (r.tipoCambio) TC = r.tipoCambio;
pintarCatalogoReq(); if (!SOLO_REQ) pintarDatalists();
}
if (r.clientes) recibirClientes(r.clientes);
if (r.proyectos) recibirProyectos(r.proyectos);
if (r.reqProyectos) recibirProyectosReq(r.reqProyectos);
if (r.misReq) pintarMisReq(r.misReq);
if (r.comProyectos) recibirProyectosCom(r.comProyectos);
if (r.comRequisiciones) pintarListaReqCom(r.comRequisiciones);
if (r.requisicion && REQC && REQC.requisicion.folio === r.requisicion.requisicion.folio) {
sinInterrumpir('comVistaReq', () => pintarReqCom(r.requisicion));
}
if (r.comprasProyecto && COM && COM.proyecto.folio === r.comprasProyecto.proyecto.folio) {
sinInterrumpir('comModOrdenes', () => pintarCompras(r.comprasProyecto));
}
if (r.cobProyectos) recibirProyectosCob(r.cobProyectos);
if (r.cobranzaProyecto && COB && COB.proyecto.folio === r.cobranzaProyecto.proyecto.folio) {
sinInterrumpir('cobVistaProyecto', () => pintarCobranza(r.cobranzaProyecto));
}
if (r.resumen) { RESUMEN = r.resumen; pintarResumenCob(); }
if (cambioRol) {
// Pestaña recién permitida: se pide lo que la sincronización no trajo
if (PERMISOS.requisicion && !CARGADO.reqProy) cargarProyectosReq();
if (PERMISOS.requisicion && !CARGADO.misReq) cargarMisReq();
if (PERMISOS.compras && !SOLO_REQ && !CARGADO.comProy) cargarProyectosCom();
if (PERMISOS.compras && !SOLO_REQ && !CARGADO.comReqs) cargarRequisicionesCom();
if (PERMISOS.cobranza && !SOLO_REQ && !CARGADO.cobProy) cargarProyectosCob();
}
}
// Una vista donde alguien está capturando no se repinta sola: se avisa y se actualiza con un clic
const SUCIO = {}, PENDIENTE = {};
function sinInterrumpir(id, pintar) {
const vista = $(id), activo = document.activeElement;
const escribiendo = vista && activo && vista.contains(activo) && /^(INPUT|TEXTAREA|SELECT)$/.test(activo.tagName);
if (!SUCIO[id] && !escribiendo) { pintar(); return; }
PENDIENTE[id] = pintar;
let a = vista.querySelector('.aviso-sync');
if (!a) { a = document.createElement('div'); a.className = 'aviso-sync nota aviso-moneda'; a.style.marginBottom = '8px'; vista.prepend(a); }
a.innerHTML = 'Otra persona cambió esta información. <a href="#">Actualizar</a> (se pierde lo que no hayas guardado).';
a.querySelector('a').onclick = ev => { ev.preventDefault(); const f = PENDIENTE[id]; listoVista(id); if (f) f(); };
}
/** La vista se acaba de pintar con datos del servidor: ya no hay captura pendiente ni aviso. */
function listoVista(id) {
SUCIO[id] = false; delete PENDIENTE[id];
const a = $(id) && $(id).querySelector('.aviso-sync');
if (a) a.remove();
}
['comVistaReq', 'cobVistaProyecto'].forEach(id => $(id).addEventListener('input', () => { SUCIO[id] = true; }));
// ---- Conceptos ---- // ---- Conceptos ----
function agregarFila() { function agregarFila() {
@ -865,8 +1003,9 @@
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…'; $('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run google.script.run
.withSuccessHandler(r => { .withSuccessHandler(r => {
const pr = r.proyectoFolio && PROYECTOS.find(p => p.folio === r.proyectoFolio);
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) + aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) +
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') + (r.proyectoFolio ? ' · Proyecto <b>' + esc(pr ? folioVis(pr) : r.proyectoFolio) + '</b>' : '') +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>'); ' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar(); limpiar();
}) })
@ -933,8 +1072,10 @@
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); }); $('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
// ---- Proyectos (opcional al cotizar) ---- // ---- Proyectos (opcional al cotizar) ----
// La opción de la lista es "PRY-2026-0001 — Cliente — Nombre" (así se puede buscar por cualquiera de los tres) // La opción de la lista es "PRY-2026-0001-TSP — Cliente — Nombre" (así se puede buscar por folio, clave, cliente o nombre).
const etiquetaProyecto = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre; // La llave interna sigue siendo PRY-AAAA-NNNN (p.folio); la clave solo se muestra.
const folioVis = p => (p && (p.folioVisible || p.folio)) || '';
const etiquetaProyecto = p => folioVis(p) + ' — ' + p.cliente + ' — ' + p.nombre;
function recibirProyectos(lista) { function recibirProyectos(lista) {
PROYECTOS = lista || []; PROYECTOS = lista || [];
@ -979,8 +1120,32 @@
} }
function cerrarNuevoProyecto() { function cerrarNuevoProyecto() {
$('nuevoProy').classList.add('oculto'); $('nuevoProy').classList.add('oculto');
$('npNombre').value = ''; $('npUbicacion').value = ''; $('npNombre').value = ''; $('npUbicacion').value = ''; $('npClave').value = ''; CLAVE_A_MANO = false; pintarFolioNuevo();
} }
// Clave del proyecto (3 letras): se sugiere con el nombre (misma regla que claveSugerida en Code.js) y se puede editar;
// el servidor la vuelve a validar y después ya no cambia
const PALABRAS_SIN_CLAVE = ['DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'Y', 'EN', 'PARA'];
function claveSugerida(nombre) {
const palabras = String(nombre || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z]+/g, ' ').trim().split(' ').filter(p => p && PALABRAS_SIN_CLAVE.indexOf(p) === -1);
let c = '';
if (palabras.length === 1) c = palabras[0].slice(0, 3);
else if (palabras.length === 2) c = palabras[0].slice(0, 2) + palabras[1].charAt(0);
else if (palabras.length > 2) c = palabras.slice(0, 3).map(p => p.charAt(0)).join('');
return (c + 'XXX').slice(0, 3);
}
let CLAVE_A_MANO = false;
function pintarFolioNuevo() {
const c = $('npClave').value.trim().toUpperCase();
$('npFolio').textContent = /^[A-Z]{3}$/.test(c) ? 'El folio será PRY-' + new Date().getFullYear() + '-NNNN-' + c + ' (el número lo asigna el sistema). La clave ya no se cambia después.'
: 'La clave debe tener exactamente 3 letras (A–Z, sin acentos ni Ñ).';
}
$('npNombre').addEventListener('input', () => { if (!CLAVE_A_MANO) $('npClave').value = $('npNombre').value.trim() ? claveSugerida($('npNombre').value) : ''; pintarFolioNuevo(); });
$('npClave').addEventListener('input', () => {
$('npClave').value = $('npClave').value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3);
CLAVE_A_MANO = !!$('npClave').value; pintarFolioNuevo();
});
function pintarClienteProyecto() { function pintarClienteProyecto() {
const n = $('cNombre').value.trim(), e = $('cEmpresa').value.trim(); const n = $('cNombre').value.trim(), e = $('cEmpresa').value.trim();
$('npCliente').textContent = n ? n + (e ? ' — ' + e : '') : '(primero escribe o elige el cliente arriba)'; $('npCliente').textContent = n ? n + (e ? ' — ' + e : '') : '(primero escribe o elige el cliente arriba)';
@ -990,6 +1155,7 @@
function crearProyecto() { function crearProyecto() {
if (!$('cNombre').value.trim()) { mostrarError('Primero elige o escribe el cliente en "Nombre / Contacto".'); return; } if (!$('cNombre').value.trim()) { mostrarError('Primero elige o escribe el cliente en "Nombre / Contacto".'); return; }
if (!$('npNombre').value.trim()) { mostrarError('Escribe el nombre del proyecto.'); return; } if (!$('npNombre').value.trim()) { mostrarError('Escribe el nombre del proyecto.'); return; }
if (!/^[A-Z]{3}$/.test($('npClave').value.trim().toUpperCase())) { mostrarError('La clave del proyecto debe tener exactamente 3 letras (A–Z).'); return; }
if ($('btnProy').disabled) return; if ($('btnProy').disabled) return;
$('btnProy').disabled = true; $('btnProy').textContent = 'Creando carpeta…'; $('btnProy').disabled = true; $('btnProy').textContent = 'Creando carpeta…';
const listo = () => { $('btnProy').disabled = false; $('btnProy').textContent = 'Crear proyecto'; }; const listo = () => { $('btnProy').disabled = false; $('btnProy').textContent = 'Crear proyecto'; };
@ -998,12 +1164,12 @@
listo(); ID_PROYECTO = nuevoId(); listo(); ID_PROYECTO = nuevoId();
recibirProyectos([p].concat(PROYECTOS.filter(x => x.folio !== p.folio))); recibirProyectos([p].concat(PROYECTOS.filter(x => x.folio !== p.folio)));
$('proyecto').value = etiquetaProyecto(p); pintarProyInfo(); cerrarNuevoProyecto(); $('proyecto').value = etiquetaProyecto(p); pintarProyInfo(); cerrarNuevoProyecto();
aviso('ok', 'Proyecto <b>' + esc(p.folio) + '</b> creado y elegido para esta cotización · ' + aviso('ok', 'Proyecto <b>' + esc(folioVis(p)) + '</b> creado y elegido para esta cotización · ' +
'<a href="' + esc(p.url) + '" target="_blank">Abrir carpeta</a>'); '<a href="' + esc(p.url) + '" target="_blank">Abrir carpeta</a>');
}) })
.withFailureHandler(err => { listo(); mostrarError(err); }) .withFailureHandler(err => { listo(); mostrarError(err); })
.crearProyecto({ .crearProyecto({
nombre: $('npNombre').value, ubicacion: $('npUbicacion').value, nombre: $('npNombre').value, ubicacion: $('npUbicacion').value, clave: $('npClave').value,
cliente: { nombre: $('cNombre').value, empresa: $('cEmpresa').value, rfc: $('cRfc').value }, cliente: { nombre: $('cNombre').value, empresa: $('cEmpresa').value, rfc: $('cRfc').value },
idSolicitud: ID_PROYECTO, idSolicitud: ID_PROYECTO,
}); });
@ -1115,15 +1281,16 @@
// ---- Cobranza (solo rol de cobranza) ---- // ---- Cobranza (solo rol de cobranza) ----
let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId(); let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId();
const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' }; const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' };
const etiquetaCob = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre; const etiquetaCob = p => folioVis(p) + ' — ' + p.cliente + ' — ' + p.nombre;
function cargarProyectosCob() { function recibirProyectosCob(lista) {
google.script.run.withSuccessHandler(lista => { COB_PROYECTOS = lista || []; CARGADO.cobProy = true;
COB_PROYECTOS = lista || [];
$('cobProyectosList').innerHTML = COB_PROYECTOS.map(p => $('cobProyectosList').innerHTML = COB_PROYECTOS.map(p =>
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') + '<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') +
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join(''); (p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosCobranza(''); }
function cargarProyectosCob() {
google.script.run.withSuccessHandler(recibirProyectosCob).withFailureHandler(mostrarError).listarProyectosCobranza('');
} }
function proyectoCobElegido() { function proyectoCobElegido() {
@ -1149,7 +1316,8 @@
function verResumenCob() { function verResumenCob() {
$('cobVistaProyecto').classList.add('oculto'); $('cobVistaResumen').classList.remove('oculto'); $('cobVistaProyecto').classList.add('oculto'); $('cobVistaResumen').classList.remove('oculto');
cargarResumenCob(); // Ya cargado: se queda (la sincronización lo mantiene al día); si no, se pide
if (RESUMEN) pintarResumenCob(); else if (BOOT) cargarResumenCob();
} }
function cargarResumenCob() { function cargarResumenCob() {
@ -1161,7 +1329,7 @@
/** Renglones del resumen que pasan los filtros (búsqueda, moneda, línea, solo con saldo). */ /** Renglones del resumen que pasan los filtros (búsqueda, moneda, línea, solo con saldo). */
function filtrarResumenCob() { function filtrarResumenCob() {
const q = $('resBuscar').value.trim().toLowerCase(), m = $('resMoneda').value, l = $('resMarca').value, conSaldo = $('resSaldo').checked; const q = $('resBuscar').value.trim().toLowerCase(), m = $('resMoneda').value, l = $('resMarca').value, conSaldo = $('resSaldo').checked;
return ((RESUMEN && RESUMEN.proyectos) || []).filter(p => (!q || (p.folio + ' ' + p.cliente + ' ' + p.nombre).toLowerCase().includes(q)) && return ((RESUMEN && RESUMEN.proyectos) || []).filter(p => (!q || (folioVis(p) + ' ' + p.cliente + ' ' + p.nombre).toLowerCase().includes(q)) &&
(!m || p.moneda === m) && (!l || p.marca === l) && (!conSaldo || (p.estadoContrato === 'Firmado' && p.saldo > 0.004))); (!m || p.moneda === m) && (!l || p.marca === l) && (!conSaldo || (p.estadoContrato === 'Firmado' && p.saldo > 0.004)));
} }
@ -1181,7 +1349,7 @@
const lista = filtrarResumenCob(); const lista = filtrarResumenCob();
$('resLista').innerHTML = lista.length ? lista.map(p => { $('resLista').innerHTML = lista.length ? lista.map(p => {
const firmado = p.estadoContrato === 'Firmado'; const firmado = p.estadoContrato === 'Firmado';
return '<tr class="fila-cat" data-folio="' + esc(p.folio) + '"><td style="min-width:190px"><b style="white-space:nowrap">' + esc(p.folio) + '</b>' + return '<tr class="fila-cat" data-folio="' + esc(p.folio) + '"><td style="min-width:190px"><b style="white-space:nowrap">' + esc(folioVis(p)) + '</b>' +
' <span class="nota">· ' + esc(MARCA_TXT[p.marca] || '') + '</span><div class="nota">' + esc(p.cliente) + ' · ' + esc(p.nombre) + ' <span class="nota">· ' + esc(MARCA_TXT[p.marca] || '') + '</span><div class="nota">' + esc(p.cliente) + ' · ' + esc(p.nombre) +
(p.estadoProyecto !== 'Activo' ? ' · ' + esc(p.estadoProyecto) : '') + '</div></td><td>' + (p.estadoProyecto !== 'Activo' ? ' · ' + esc(p.estadoProyecto) : '') + '</div></td><td>' +
'<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '" style="white-space:nowrap">' + esc(p.estadoContrato) + '</span></td>' + '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '" style="white-space:nowrap">' + esc(p.estadoContrato) + '</span></td>' +
@ -1203,7 +1371,7 @@
const num = n => n == null ? '' : r2(n).toFixed(2); const num = n => n == null ? '' : r2(n).toFixed(2);
const enc = ['Folio', 'Proyecto', 'Cliente', 'Línea de negocio', 'Contrato', 'Moneda', 'Contratado con IVA', 'Cobrado', 'Saldo', 'Avance %', const enc = ['Folio', 'Proyecto', 'Cliente', 'Línea de negocio', 'Contrato', 'Moneda', 'Contratado con IVA', 'Cobrado', 'Saldo', 'Avance %',
'Siguiente etapa', 'Saldo siguiente etapa', 'Último pago', 'Órdenes de compra', 'Costo OC sin IVA', 'Margen sin IVA', 'Margen %']; 'Siguiente etapa', 'Saldo siguiente etapa', 'Último pago', 'Órdenes de compra', 'Costo OC sin IVA', 'Margen sin IVA', 'Margen %'];
const filas = lista.map(p => [texto(p.folio), texto(p.nombre), texto(p.cliente), texto(MARCA_TXT[p.marca] || ''), texto(p.estadoContrato), const filas = lista.map(p => [texto(folioVis(p)), texto(p.nombre), texto(p.cliente), texto(MARCA_TXT[p.marca] || ''), texto(p.estadoContrato),
texto(p.moneda), num(p.contratado), num(p.pagado), num(p.saldo), String(p.avance), texto(p.siguienteEtapa), texto(p.moneda), num(p.contratado), num(p.pagado), num(p.saldo), String(p.avance), texto(p.siguienteEtapa),
p.siguienteEtapa ? num(p.siguienteSaldo) : '', texto(p.ultimoPago), String(p.ordenes), num(p.costoOc), num(p.margen), p.siguienteEtapa ? num(p.siguienteSaldo) : '', texto(p.ultimoPago), String(p.ordenes), num(p.costoOc), num(p.margen),
p.margenPorcentaje == null ? '' : String(p.margenPorcentaje)].join(',')); p.margenPorcentaje == null ? '' : String(p.margenPorcentaje)].join(','));
@ -1225,7 +1393,8 @@
const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado'; const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado';
const mon = c ? c.moneda : 'MXN'; const mon = c ? c.moneda : 'MXN';
$('cobProyecto').value = etiquetaCob(v.proyecto); $('cobProyecto').value = etiquetaCob(v.proyecto);
$('cobInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) + listoVista('cobVistaProyecto');
$('cobInfo').innerHTML = '<b>' + esc(folioVis(v.proyecto)) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') + (v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') +
' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada'); ' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada');
$('cobDetalle').classList.remove('oculto'); $('cobDetalle').classList.remove('oculto');
@ -1274,6 +1443,10 @@
const mg = v.margen; const mg = v.margen;
$('cobMargen').classList.toggle('oculto', !mg); $('cobMargen').classList.toggle('oculto', !mg);
$('cobMargen').innerHTML = !mg ? '' : mg.error ? '<div class="nota aviso-moneda">' + esc(mg.error) + '</div>' $('cobMargen').innerHTML = !mg ? '' : mg.error ? '<div class="nota aviso-moneda">' + esc(mg.error) + '</div>'
// Sin cotización aceptada no hay contratado: solo el costo de las órdenes de compra (cada moneda aparte)
: mg.sinContrato ? '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Costo de órdenes de compra (sin IVA)</div>' +
mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', dinero(x.monto, x.moneda)]).concat([['Margen', 'Sin contrato']])
.map(([k, x]) => '<div class="tile"><span>' + esc(k) + '</span><b>' + esc(x) + '</b></div>').join('')
: '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Margen del proyecto (sin IVA)</div>' + : '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Margen del proyecto (sin IVA)</div>' +
[['Contratado sin IVA', dinero(mg.contratado, mg.moneda)]] [['Contratado sin IVA', dinero(mg.contratado, mg.moneda)]]
.concat(mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', '− ' + dinero(x.monto, mg.moneda)])) .concat(mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', '− ' + dinero(x.monto, mg.moneda)]))
@ -1439,11 +1612,14 @@
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join(''); '<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join('');
} }
// Proyectos Activos y Cerrados (con o sin contrato); la opción es "PRY-2026-0002-DRO — Nombre"
function recibirProyectosReq(lista) {
REQ_PROYECTOS = lista || []; CARGADO.reqProy = true;
$('reqProyectosList').innerHTML = REQ_PROYECTOS.map(p => '<option value="' + esc(folioVis(p) + ' — ' + p.nombre) + '"' +
(p.estado && p.estado !== 'Activo' ? ' label="' + esc(p.estado) + '"' : '') + '>').join('');
}
function cargarProyectosReq() { function cargarProyectosReq() {
google.script.run.withSuccessHandler(lista => { google.script.run.withSuccessHandler(recibirProyectosReq).withFailureHandler(mostrarError).listarProyectosRequisicion('');
REQ_PROYECTOS = lista || [];
$('reqProyectosList').innerHTML = REQ_PROYECTOS.map(p => '<option value="' + esc(p.folio + ' — ' + p.nombre) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosRequisicion('');
} }
function cargarMisReq() { function cargarMisReq() {
google.script.run.withSuccessHandler(pintarMisReq).withFailureHandler(mostrarError).misRequisiciones(); google.script.run.withSuccessHandler(pintarMisReq).withFailureHandler(mostrarError).misRequisiciones();
@ -1518,10 +1694,10 @@
} }
function pintarMisReq(lista) { function pintarMisReq(lista) {
MIS_REQ = lista || []; MIS_REQ = lista || []; CARGADO.misReq = true;
$('reqLista').innerHTML = !MIS_REQ.length ? '<div class="nota">Todavía no tienes requisiciones.</div>' : MIS_REQ.map(r => $('reqLista').innerHTML = !MIS_REQ.length ? '<div class="nota">Todavía no tienes requisiciones.</div>' : MIS_REQ.map(r =>
'<div class="sat" style="margin-top:10px">' + '<div class="sat" style="margin-top:10px">' +
'<div class="sat-titulo">' + esc(r.folio) + ' · ' + chipReq(r.estado) + ' · ' + esc(r.proyectoFolio) + ' — ' + esc(r.proyectoNombre) + '</div>' + '<div class="sat-titulo">' + esc(r.folio) + ' · ' + chipReq(r.estado) + ' · ' + esc(r.proyectoVisible || r.proyectoFolio) + ' — ' + esc(r.proyectoNombre) + '</div>' +
(r.motivo && ['Devuelta', 'Rechazada', 'Cancelada'].indexOf(r.estado) !== -1 ? '<div class="nota"><b>Motivo:</b> ' + esc(r.motivo) + '</div>' : '') + (r.motivo && ['Devuelta', 'Rechazada', 'Cancelada'].indexOf(r.estado) !== -1 ? '<div class="nota"><b>Motivo:</b> ' + esc(r.motivo) + '</div>' : '') +
'<ul style="margin:6px 0;padding-left:18px">' + r.renglones.map(x => '<li>' + esc(x.cantidad) + ' ' + esc(x.unidad) + ' — ' + '<ul style="margin:6px 0;padding-left:18px">' + r.renglones.map(x => '<li>' + esc(x.cantidad) + ' ' + esc(x.unidad) + ' — ' +
esc(x.descripcion) + (x.notas ? ' <span class="nota">(' + esc(x.notas) + ')</span>' : '') + '</li>').join('') + '</ul>' + esc(x.descripcion) + (x.notas ? ' <span class="nota">(' + esc(x.notas) + ')</span>' : '') + '</li>').join('') + '</ul>' +
@ -1539,7 +1715,7 @@
const r = MIS_REQ.find(x => x.folio === folio); const r = MIS_REQ.find(x => x.folio === folio);
if (!r) return; if (!r) return;
REQ_EDITANDO = folio; ID_REQ = nuevoId(); REQ_EDITANDO = folio; ID_REQ = nuevoId();
$('reqProyecto').value = r.proyectoFolio + ' — ' + r.proyectoNombre; $('reqProyecto').disabled = true; $('reqProyecto').value = (r.proyectoVisible || r.proyectoFolio) + ' — ' + r.proyectoNombre; $('reqProyecto').disabled = true;
$('reqFilas').innerHTML = ''; r.renglones.forEach(agregarRenglonReq); $('reqFilas').innerHTML = ''; r.renglones.forEach(agregarRenglonReq);
$('reqArchivo').value = ''; $('reqArchivo').value = '';
$('reqTitulo').textContent = '1 · Corregir ' + folio; $('reqTitulo').textContent = '1 · Corregir ' + folio;
@ -1571,16 +1747,19 @@
REQC = null; REQC = null;
$('comVistaReq').classList.add('oculto'); $('comVistaLista').classList.remove('oculto'); $('comVistaReq').classList.add('oculto'); $('comVistaLista').classList.remove('oculto');
$('rcCotVisor').src = 'about:blank'; $('rcCotVisor').src = 'about:blank';
cargarRequisicionesCom(); if (BOOT && !CARGADO.comReqs) cargarRequisicionesCom(); // ya cargada: la sincronización la mantiene al día
} }
// Filtro con que se pidió la lista (la sincronización la vuelve a pedir igual, aunque haya texto sin buscar)
const COM_FILTRO = { buscar: '', estado: '' };
function cargarRequisicionesCom() { function cargarRequisicionesCom() {
google.script.run.withSuccessHandler(pintarListaReqCom).withFailureHandler(mostrarError) COM_FILTRO.buscar = $('rcBuscar').value; COM_FILTRO.estado = $('rcEstado').value;
.listarRequisiciones($('rcBuscar').value, $('rcEstado').value); google.script.run.withSuccessHandler(l => { CARGADO.comReqs = true; pintarListaReqCom(l); }).withFailureHandler(mostrarError)
.listarRequisiciones(COM_FILTRO.buscar, COM_FILTRO.estado);
} }
$('rcBuscar').addEventListener('keydown', e => { if (e.key === 'Enter') cargarRequisicionesCom(); }); $('rcBuscar').addEventListener('keydown', e => { if (e.key === 'Enter') cargarRequisicionesCom(); });
function pintarListaReqCom(lista) { function pintarListaReqCom(lista) {
$('rcLista').innerHTML = (lista || []).length ? lista.map(r => '<tr class="fila-cat" onclick="abrirReqCom(\'' + esc(r.folio) + '\')">' + $('rcLista').innerHTML = (lista || []).length ? lista.map(r => '<tr class="fila-cat" onclick="abrirReqCom(\'' + esc(r.folio) + '\')">' +
'<td style="white-space:nowrap"><b>' + esc(r.folio) + '</b></td><td>' + esc(fechaMx(r.fecha)) + '</td><td>' + esc(r.proyectoFolio) + '<td style="white-space:nowrap"><b>' + esc(r.folio) + '</b></td><td>' + esc(fechaMx(r.fecha)) + '</td><td>' + esc(r.proyectoVisible || r.proyectoFolio) +
'<div class="nota">' + esc(r.proyectoNombre) + '</div></td><td>' + esc(r.solicitante) + '</td><td class="num">' + esc(r.renglones) + '<div class="nota">' + esc(r.proyectoNombre) + '</div></td><td>' + esc(r.solicitante) + '</td><td class="num">' + esc(r.renglones) +
'</td><td>' + chipReq(r.estado) + '</td><td>' + esc(r.ocFolio) + '</td></tr>').join('') '</td><td>' + chipReq(r.estado) + '</td><td>' + esc(r.ocFolio) + '</td></tr>').join('')
: '<tr><td colspan="7" class="nota">No hay requisiciones con ese filtro.</td></tr>'; : '<tr><td colspan="7" class="nota">No hay requisiciones con ese filtro.</td></tr>';
@ -1594,12 +1773,14 @@
const rq = v.requisicion; const rq = v.requisicion;
const cambio = !REQC || REQC.requisicion.folio !== rq.folio || REQC.requisicion.estado !== rq.estado; const cambio = !REQC || REQC.requisicion.folio !== rq.folio || REQC.requisicion.estado !== rq.estado;
REQC = v; REQC = v;
listoVista('comVistaReq');
$('comVistaLista').classList.add('oculto'); $('comVistaReq').classList.remove('oculto'); $('comVistaLista').classList.add('oculto'); $('comVistaReq').classList.remove('oculto');
$('rcTitulo').textContent = '1 · Requisición ' + rq.folio; $('rcTitulo').textContent = '1 · Requisición ' + rq.folio;
$('rcInfo').innerHTML = chipReq(rq.estado) + ' · <b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + $('rcInfo').innerHTML = chipReq(rq.estado) + ' · <b>' + esc(folioVis(v.proyecto)) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' +
esc(v.proyecto.nombre) + (v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') + esc(v.proyecto.nombre) + (v.proyecto.estado && v.proyecto.estado !== 'Activo' ? ' · ' + esc(v.proyecto.estado) : '') +
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + (v.sinContrato ? 'sin contrato' : esc(MARCA_TXT[v.marca] || '')) : '') +
'<br>Pidió: ' + esc(rq.solicitante) + ' (' + esc(rq.solicitanteEmail) + ') el ' + esc(fechaMx(rq.fecha)) + '<br>Pidió: ' + esc(rq.solicitante) + ' (' + esc(rq.solicitanteEmail) + ') el ' + esc(fechaMx(rq.fecha)) +
(rq.estado === 'Solicitada' && !v.puedeAutorizar ? '<br><span class="aviso-moneda">El proyecto ya no está activo con contrato firmado: no se puede autorizar (recházala o devuélvela).</span>' : ''); (rq.estado === 'Solicitada' && !v.puedeAutorizar ? '<br><span class="aviso-moneda">El proyecto está cancelado: no se puede autorizar (recházala o devuélvela).</span>' : '');
$('rcRenglones').innerHTML = v.renglones.map(x => '<tr><td>' + esc(x.renglon) + '</td><td>' + esc(x.descripcion) + '</td><td class="num">' + $('rcRenglones').innerHTML = v.renglones.map(x => '<tr><td>' + esc(x.renglon) + '</td><td>' + esc(x.descripcion) + '</td><td class="num">' +
esc(x.cantidad) + '</td><td>' + esc(x.unidad) + '</td><td>' + esc(x.notas) + '</td></tr>').join(''); esc(x.cantidad) + '</td><td>' + esc(x.unidad) + '</td><td>' + esc(x.notas) + '</td></tr>').join('');
$('rcEventos').innerHTML = '<div class="sat-titulo">Historial</div><div class="nota">' + v.eventos.map(e => esc(fechaMx(e.fecha)) + $('rcEventos').innerHTML = '<div class="sat-titulo">Historial</div><div class="nota">' + v.eventos.map(e => esc(fechaMx(e.fecha)) +
@ -1814,13 +1995,16 @@
} }
// Órdenes por proyecto (consulta) // Órdenes por proyecto (consulta)
function cargarProyectosCom() { // Proyectos Activos y Cerrados, con o sin contrato (sin contrato, las órdenes van en MXN)
google.script.run.withSuccessHandler(lista => { function recibirProyectosCom(lista) {
COM_PROYECTOS = lista || []; COM_PROYECTOS = lista || []; CARGADO.comProy = true;
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p => $('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (MARCA_TXT[p.marca] || '')) + '">').join(''); '<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (p.sinContrato ? 'sin contrato' : (MARCA_TXT[p.marca] || '')) +
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos con contrato firmado.'; (p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosCompras(''); if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos ni cerrados.';
}
function cargarProyectosCom() {
google.script.run.withSuccessHandler(recibirProyectosCom).withFailureHandler(mostrarError).listarProyectosCompras('');
} }
function proyectoComElegido() { function proyectoComElegido() {
@ -1838,8 +2022,8 @@
function pintarCompras(v) { function pintarCompras(v) {
COM = v; COM = v;
$('comProyecto').value = etiquetaCob(v.proyecto); $('comProyecto').value = etiquetaCob(v.proyecto);
$('comInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) + $('comInfo').innerHTML = '<b>' + esc(folioVis(v.proyecto)) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') + (v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + (v.sinContrato ? 'sin contrato' : esc(MARCA_TXT[v.marca] || '')) : '') +
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : ''); (v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '');
$('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' + $('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' +
esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) + esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) +
@ -1861,7 +2045,8 @@
} }
// ---- UI ---- // ---- UI ----
function verTab(t) { function verTab(t, recienCargado) {
TAB = t;
$('vistaNueva').classList.toggle('oculto', t !== 'nueva'); $('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist'); $('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat'); $('vistaCat').classList.toggle('oculto', t !== 'cat');
@ -1876,9 +2061,16 @@
$('tabHist').classList.toggle('activo', t === 'hist'); $('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial(); if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo(); if (t === 'cat') cargarCatalogo();
if (t === 'cob') { cargarProyectosCob(); verResumenCob(); } if (t === 'cob') verResumenCob();
if (t === 'com') { cargarProyectosCom(); verListaCom(); } if (t === 'com') verListaCom();
if (t === 'req') { cargarProyectosReq(); cargarMisReq(); if (!$('reqFilas').querySelectorAll('tr').length) limpiarReq(); } if (t === 'req' && !$('reqFilas').querySelectorAll('tr').length) limpiarReq();
// Lo que trajo el bootstrap ya está; si una lista no llegó, se pide. Lo demás lo pone al día la sincronización
if (BOOT) {
if (t === 'req') { if (!CARGADO.reqProy) cargarProyectosReq(); if (!CARGADO.misReq) cargarMisReq(); }
if (t === 'cob' && !CARGADO.cobProy) cargarProyectosCob();
if (t === 'com' && !CARGADO.comProy) cargarProyectosCom();
if (!recienCargado) sincronizarAhora(); // justo después del bootstrap no hace falta
}
$('aviso').className = 'aviso'; $('aviso').className = 'aviso';
} }
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); } function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }