mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
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:
parent
070b45dffb
commit
d56dd38e02
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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'}}})})})},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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)});
|
||||||
|
|||||||
@ -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'}),
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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})}),
|
||||||
|
|||||||
@ -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}})},
|
||||||
|
|||||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user