mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
- .oculto con !important: las clases con display propio (.acciones, .tiles) definidas después le ganaban y seguían visibles los botones Devolver/Rechazar (y podía pasar con la tarjeta de margen de Cobranza). Prueba en interfaz.test.js. - Pago a proveedor en otra moneda: el aviso dice "La orden es en MXN…" (antes "El proyecto…"). Prueba en requisiciones.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
34 KiB
JavaScript
380 lines
34 KiB
JavaScript
// Requisiciones de materiales y pago a proveedores: el solicitante (ROL_REQUISICION / SOLO_REQUISICION) solo ve las suyas y
|
||
// nunca montos, cliente, margen, pagos ni órdenes; compras (ROL_COMPRAS) autoriza (genera la OC), rechaza, devuelve, paga y
|
||
// puede cerrar con motivo. Estados válidos e inválidos, folios REQ/PGP, conversión a OC, pago (comprobante, monto = total de la
|
||
// OC, tipo de cambio), cancelar el pago y cierre. Todo por doPost con tokens simulados (mismos simulacros que compras.test.js).
|
||
// Uso: node tests/requisiciones.test.js . (carpeta con Code.js y Logo.js)
|
||
const fs=require('fs'),vm=require('vm'),assert=require('assert'),path=require('path');
|
||
const dir=process.argv[2];
|
||
let src=fs.readFileSync(path.join(dir,'Logo.js'),'utf8')+'\n'+fs.readFileSync(path.join(dir,'Code.js'),'utf8');
|
||
const CID='123-abc.apps.googleusercontent.com';
|
||
src=src.replace(/const CLIENT_ID_GOOGLE = '[^']*';/,"const CLIENT_ID_GOOGLE = '"+CID+"';");
|
||
const ANIO=String(new Date().getFullYear());
|
||
const pad=n=>String(n).padStart(2,'0');
|
||
const HOY=new Date(), HOY_ISO=HOY.getFullYear()+'-'+pad(HOY.getMonth()+1)+'-'+pad(HOY.getDate());
|
||
|
||
// ---- Hojas y libros simulados ----
|
||
// Como Sheets: un texto que parece fecha ("2026-10-20", "2026-10-20 17:30") se guarda como fecha; con apóstrofo
|
||
// inicial se guarda como texto (y getValues lo devuelve sin el apóstrofo).
|
||
const comoSheets=v=>{if(typeof v!=='string')return v;if(v[0]==="'")return v.slice(1);
|
||
const x=v.match(/^(\d{4})-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/);
|
||
return x?new Date(+x[1],+x[2]-1,+x[3],+(x[4]||0),+(x[5]||0),+(x[6]||0)):v};
|
||
function hoja(m){const h={m,getLastRow:()=>m.length,getMaxRows:()=>1000,setFrozenRows(){},setColumnWidth(){},
|
||
getDataRange:()=>({getValues:()=>m.map(r=>r.slice()),getDisplayValues:()=>m.map(r=>r.map(x=>x instanceof Date?'20/10/2026':String(x??'')))}),
|
||
appendRow:a=>m.push(a.map(comoSheets)),deleteRow:n=>m.splice(n-1,1),
|
||
getRange:(r,c=1,nr=1,nc=1)=>typeof r==='string'?{setNumberFormat(){return this},setDataValidation(){return this}}:({
|
||
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]=comoSheets(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]=comoSheets(y))});return this},
|
||
setNumberFormat(){return this},setFontWeight(){return this},setBackground(){return this},setFontColor(){return this},setDataValidation(){return this}})};
|
||
return h}
|
||
const libros={};
|
||
function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:()=>id,getUrl:()=>'https://sheets/'+id,
|
||
getSheetByName:n=>L.pest[n]||null,getSheets:()=>Object.values(L.pest),
|
||
insertSheet:n=>{if(L.pest[n])throw new Error('ya existe');return L.pest[n]=hoja([])},
|
||
deleteSheet:h=>{for(const k in L.pest)if(L.pest[k]===h)delete L.pest[k]}};
|
||
libros[id]=L;return L}
|
||
|
||
// ---- Drive simulado ----
|
||
let nId=0; const carpetas={}, archivos={};
|
||
// Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN')
|
||
// DriveApp.setSharing da "Access denied" en helgen.tech: el código usa la API de Drive (permissions) con el token del script
|
||
const compartible=o=>Object.assign(o,{acceso:'DOMAIN',setSharing:()=>{throw new Error('Access denied: DriveApp.')}});
|
||
function apiDrive(url,op){const m=url.match(/drive\/v3\/files\/([^/?]+)\/permissions(?:\/([^?]+))?/);
|
||
const it=carpetas[decodeURIComponent(m[1])]||archivos[decodeURIComponent(m[1])];
|
||
assert(op&&op.headers&&op.headers.Authorization==='Bearer TOKEN-SCRIPT','API de Drive con el token del script');
|
||
const resp=(c,b)=>({getResponseCode:()=>c,getContentText:()=>JSON.stringify(b||{})});
|
||
if(!it)return resp(404);
|
||
// Como Drive: el acceso del dominio se hereda de las carpetas y no se puede quitar en el hijo mientras el padre lo tenga
|
||
let heredado=false;for(let f=it.padre;f;f=f.padre)if(f.acceso==='DOMAIN')heredado=true;
|
||
if(op.method==='delete'){assert.equal(m[2],'dom');if(heredado)return resp(403,{error:{reason:'cannotDeletePermission'}});it.acceso='PRIVATE';return resp(204)}
|
||
return resp(200,{permissions:[{id:'own',type:'user'}].concat(it.acceso==='DOMAIN'||heredado?[{id:'dom',type:'domain'}]:[])})}
|
||
function carpeta(nombre,padre){const f=compartible({id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false}); Object.assign(f,{
|
||
getId:()=>f.id,getName:()=>f.nombre,getUrl:()=>'https://drive/'+f.id,isTrashed:()=>f.trashed,
|
||
getFoldersByName:n=>{const l=f.hijos.filter(h=>h.nombre===n);let i=0;return{hasNext:()=>i<l.length,next:()=>l[i++]}},
|
||
createFolder:n=>{const h=carpeta(n,f);f.hijos.push(h);return h},
|
||
createFile:b=>{const id='A'+(++nId);const a=compartible({id,blob:b,padre:f});archivos[id]=a;f.archivos.push(a);return Object.assign(a,{getId:()=>id,getUrl:()=>'https://drive/file/'+id})}});
|
||
carpetas[f.id]=f;return f}
|
||
const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)');
|
||
miUnidad.acceso='PRIVATE'; // la raíz de Mi unidad no se comparte; la carpeta maestra sí (todo el dominio, §6.11)
|
||
const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre;
|
||
archivos.DB={id:'DB',padre:maestra};
|
||
|
||
const db=libro('DB','Base de datos',{Config:hoja([['Clave','Valor'],['PREFIJO_FOLIO','HLG'],['IVA_TASA',0.16]]),Cotizaciones:hoja([])});
|
||
libro('CAT','Catálogo',{Catalogo:hoja([['Clave','Descripción','Unidad','Precio unitario (sin IVA)','Actualizado','Actualizó','Clave SAT','Unidad SAT'],['PAN-550','Panel solar 550 W','PZA',4100,'','','','']])});
|
||
const props={SPREADSHEET_ID:'DB',FOLDER_ID:pdfRaiz.id,CATALOGO_ID:'CAT',
|
||
ROL_COBRANZA:'efrain@helgen.tech, tania@helgen.tech',ADMIN_DESBLOQUEO:'efrain@helgen.tech',
|
||
ROL_COMPRAS:'efrain@helgen.tech, pedro@helgen.tech',ROL_REQUISICION:'obrero@helgen.tech, rosa@helgen.tech, pedro@helgen.tech',
|
||
SOLO_REQUISICION:'luis@helgen.tech, rosa@helgen.tech'},cache={};
|
||
|
||
// ---- Tokens: efrain (cobranza + compras + admin), tania (cobranza), pedro (compras + requisiciones), ana (vendedora sin rol),
|
||
// obrero (ROL_REQUISICION), rosa y luis (SOLO_REQUISICION: solo la pestaña Requisiciones; luis no está en ROL_REQUISICION) ----
|
||
const ahora=Math.floor(Date.now()/1000);
|
||
const claims=(email,n,a)=>({aud:CID,iss:'https://accounts.google.com',exp:String(ahora+3000),email,email_verified:'true',hd:'helgen.tech',given_name:n,family_name:a});
|
||
const tokens={efrain:claims('efrain@helgen.tech','Efrain','Sosa'),tania:claims('tania@helgen.tech','Tania','R'),
|
||
ana:claims('ana@helgen.tech','Ana','López'),obrero:claims('obrero@helgen.tech','Juan','Obrero'),rosa:claims('rosa@helgen.tech','Rosa','Mena'),luis:claims('luis@helgen.tech','Luis','Campo'),pedro:claims('pedro@helgen.tech','Pedro','Ruiz'),intruso:{...claims('intruso@gmail.com','X','Y'),hd:undefined}};
|
||
|
||
const fmt=(d,tz,f)=>f.replace('yyyy',d.getFullYear()).replace('MM',pad(d.getMonth()+1)).replace('dd',pad(d.getDate()))
|
||
.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 ctx={console:{...console,warn(){}},Date,JSON,
|
||
PropertiesService:{getScriptProperties:()=>({getProperty:k=>props[k]??null,setProperty:(k,v)=>props[k]=v})},
|
||
CacheService:{getScriptCache:()=>({get:k=>cache[k]??null,put:(k,v)=>cache[k]=v})},
|
||
LockService:{getScriptLock:()=>({tryLock:()=>true,releaseLock(){}})},
|
||
Session:{getActiveUser:()=>({getEmail:()=>''})},
|
||
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},
|
||
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()]},
|
||
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];
|
||
return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}},
|
||
ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})},
|
||
SpreadsheetApp:{flush(){},openById:id=>{if(!libros[id])throw new Error('No encontrado');return libros[id]},
|
||
create:n=>{const L=libro('S'+(++nId),n,{'Hoja 1':hoja([])});archivos[L.id]=compartible({id:L.id,padre:miUnidad});return L},
|
||
newDataValidation:()=>{const b={requireValueInList:()=>b,setAllowInvalid:()=>b,setHelpText:()=>b,build:()=>({})};return b}},
|
||
DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
|
||
createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad,
|
||
getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}},
|
||
moveTo:f=>{archivos[id].padre=f},setSharing:()=>archivos[id].setSharing(),getId:()=>id,getUrl:()=>'https://drive/file/'+id}),
|
||
Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}},
|
||
};
|
||
vm.createContext(ctx); vm.runInContext(src,ctx);
|
||
const post=(quien,funcion,...args)=>JSON.parse(ctx.doPost({postData:{contents:JSON.stringify({funcion,args,token:quien})}}).t);
|
||
const ok=(quien,funcion,...args)=>{const r=post(quien,funcion,...args);assert(r.ok,funcion+': '+r.error);return r.datos};
|
||
const falla=(quien,funcion,args,re,codigo)=>{const r=post(quien,funcion,...args);assert.equal(r.ok,false,funcion+' debía fallar');
|
||
assert(re.test(r.error),funcion+': '+r.error);if(codigo)assert.equal(r.codigo,codigo,funcion+' codigo');return r};
|
||
|
||
const MANANA=(()=>{const d=new Date(HOY.getTime()+86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||
const AYER=(()=>{const d=new Date(HOY.getTime()-86400000);return d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())})();
|
||
function vivos(f){return f.hijos.filter(h=>!h.trashed)}
|
||
|
||
|
||
const PDF=Buffer.from('%PDF-1.4 cotizacion del proveedor').toString('base64');
|
||
const JPG=Buffer.from([0xFF,0xD8,0xFF,0xE0,1,2,3]).toString('base64');
|
||
const REQ=f=>'REQ-'+ANIO+'-'+f, OC=f=>'OC-'+ANIO+'-'+f, PGP=f=>'PGP-'+ANIO+'-'+f;
|
||
|
||
// ================= Lógica pura =================
|
||
const P=ctx;
|
||
const rl=P.normalizarRequisicion({renglones:[{descripcion:' Cable THW 10 ',cantidad:'25.5',unidad:'m',notas:' rojo '},{descripcion:'',cantidad:3}]});
|
||
assert.deepEqual(rl.map(r=>({...r})),[{descripcion:'Cable THW 10',cantidad:25.5,unidad:'M',notas:'rojo'}],'limpia y quita renglones vacíos');
|
||
assert.equal(P.normalizarRequisicion({renglones:[{descripcion:'Tornillo',cantidad:1}]})[0].unidad,'PZA','unidad por omisión');
|
||
for(const [mal,re] of [[[],/al menos un renglón/],[[{descripcion:'A',cantidad:0}],/cantidad de "A" debe ser mayor a 0/],
|
||
[[{descripcion:'A',cantidad:-1}],/mayor a 0/],[[{descripcion:'A',cantidad:''}],/mayor a 0/],[[{descripcion:'A',cantidad:'x'}],/mayor a 0/],
|
||
[[{descripcion:'A'.repeat(501),cantidad:1}],/500 caracteres/],[[{descripcion:'A',cantidad:1,notas:'n'.repeat(301)}],/300 caracteres/],
|
||
[Array.from({length:101},()=>({descripcion:'A',cantidad:1})),/máximo 100 renglones/]]){
|
||
assert.throws(()=>P.normalizarRequisicion({renglones:mal}),re,JSON.stringify(mal).slice(0,60));
|
||
}
|
||
// Estados: todas las transiciones válidas y algunas inválidas
|
||
const validas={autorizar:['Solicitada','Autorizada'],rechazar:['Solicitada','Rechazada'],devolver:['Solicitada','Devuelta'],
|
||
reenviar:['Devuelta','Solicitada'],cancelar:['Solicitada','Cancelada'],pagar:['Autorizada','Pagada'],cancelarPago:['Pagada','Autorizada'],
|
||
cerrar:['Pagada','Cerrada'],cancelarOc:['Autorizada','Solicitada']};
|
||
for(const [acc,[de,a]] of Object.entries(validas)) assert.equal(P.transicionReq(de,acc),a,acc);
|
||
assert.equal(P.transicionReq('Devuelta','cancelar'),'Cancelada','también se cancela una devuelta');
|
||
const ESTADOS=['Solicitada','Autorizada','Pagada','Cerrada','Rechazada','Devuelta','Cancelada'];
|
||
for(const acc of Object.keys(validas)) for(const e of ESTADOS){
|
||
let paso=true; try{P.transicionReq(e,acc)}catch(x){paso=false;assert(/no se puede/.test(x.message),x.message)}
|
||
const esperado=e===validas[acc][0]||(acc==='cancelar'&&e==='Devuelta');
|
||
assert.equal(paso,esperado,acc+' desde '+e);
|
||
}
|
||
for(const e of ['Cerrada','Rechazada','Cancelada']) for(const acc of Object.keys(validas)) assert.throws(()=>P.transicionReq(e,acc),/no se puede/,'final: '+e);
|
||
assert.throws(()=>P.transicionReq('Solicitada','borrar'),/Acción no válida/);
|
||
// Pago a proveedor: total de la OC ±1.00, tipo de cambio si la moneda es otra
|
||
const ORDEN={moneda:'MXN',total:2320,fechaOrden:'2026-10-20',hoy:'2026-10-22'};
|
||
const pp=P.normalizarPagoProveedor({fecha:'2026-10-21',monto:'2320.9',metodo:'transferencia',referencia:' SPEI 9 '},ORDEN);
|
||
assert.deepEqual([pp.fecha,pp.monto,pp.monedaPago,pp.tipoCambio,pp.montoAplicado,pp.metodo,pp.referencia],['2026-10-21',2320.9,'MXN','',2320.9,'Transferencia','SPEI 9']);
|
||
assert.equal(P.normalizarPagoProveedor({fecha:'2026-10-21',monto:2319,metodo:'Efectivo'},ORDEN).referencia,'','efectivo sin referencia');
|
||
assert.equal(P.normalizarPagoProveedor({fecha:'2026-10-21',monto:125.41,monedaPago:'USD',tipoCambio:18.5,metodo:'Cheque',referencia:'1'},ORDEN).montoAplicado,2320.09);
|
||
for(const [mal,re] of [[{monto:2321.01},/debe ser igual al total de la orden \(\$2,320\.00 MXN\)/],[{monto:2318.99},/igual al total/],
|
||
[{monto:0},/mayor a 0/],[{monedaPago:'USD',monto:125.41},/La orden es en MXN y el pago en USD: captura el tipo de cambio/],[{metodo:'Bitcoin'},/método de pago/],
|
||
[{referencia:''},/referencia/],[{fecha:'2026-10-23'},/no puede ser futura/],[{fecha:'2026-10-19'},/anterior a la orden/],[{fecha:''},/obligatoria/]]){
|
||
assert.throws(()=>P.normalizarPagoProveedor({fecha:'2026-10-21',monto:2320,metodo:'Transferencia',referencia:'R',...mal},ORDEN),re,JSON.stringify(mal));
|
||
}
|
||
assert.deepEqual([P.nombreArchivoReq(REQ('0001'),'Cotización','pdf',1),P.nombreArchivoReq(REQ('0001'),'Comprobante pago','jpg',2)],
|
||
[REQ('0001')+' — Cotización.pdf',REQ('0001')+' — Comprobante pago (2).jpg']);
|
||
assert.throws(()=>P.validarArchivo([],'la cotización del proveedor','La cotización'),/Adjunta la cotización del proveedor/);
|
||
assert.throws(()=>P.validarArchivo([1,2,3,4,5,6,7,8,9,10,11,12],'la cotización del proveedor','La cotización'),/La cotización debe ser PDF o imagen/);
|
||
assert.throws(()=>P.validarComprobante([]),/Adjunta el comprobante de pago/,'el mensaje de cobranza no cambia');
|
||
|
||
// ================= Servidor (doPost) =================
|
||
const proy=(nombre,id,ubicacion)=>ok('ana','crearProyecto',{nombre,ubicacion,cliente:{nombre:'Cliente Secreto',empresa:'Empresa Oculta SA'},idSolicitud:id}).folio;
|
||
const p1=proy('Techo solar','p1','Querétaro, Qro.'), p2=proy('Planta Texas','p2','Austin, TX'), p3=proy('Sin firmar','p3','');
|
||
const cot=(proyectoFolio,precio,extra)=>ok('ana','guardarCotizacion',{cliente:{nombre:'Cliente Secreto',empresa:'Empresa Oculta SA'},
|
||
items:[{descripcion:'Servicio',cantidad:1,precio}],proyectoFolio,idSolicitud:'c'+(++nId),...extra}).folio;
|
||
const c1=cot(p1,98765), c2=cot(p2,5000,{moneda:'USD'}), c3=cot(p3,100);
|
||
for(const [p,c] of [[p1,c1],[p2,c2]]){ok('efrain','marcarAceptada',p,c,true);ok('efrain','firmarContrato',p,HOY_ISO)}
|
||
ok('efrain','marcarAceptada',p3,c3);
|
||
|
||
// --- Permisos de la pestaña ---
|
||
assert.deepEqual({...ok('obrero','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:false,requisicion:true});
|
||
assert.deepEqual({...ok('pedro','getDatosIniciales').permisos},{cobranza:false,desbloqueo:false,compras:true,requisicion:true});
|
||
assert.equal(ok('ana','getDatosIniciales').permisos.requisicion,false);
|
||
const TRAB=[['listarProyectosRequisicion',['']],['misRequisiciones',[]],['guardarRequisicion',[{}]],['cancelarRequisicion',[REQ('0001'),'motivo largo']],
|
||
['cerrarRequisicion',[REQ('0001'),'']]];
|
||
for(const quien of ['ana','tania']) for(const [f,args] of TRAB) falla(quien,f,args,/No tienes permiso para hacer requisiciones/,'PERMISO');
|
||
for(const [f,args] of TRAB) falla('intruso',f,args,/@helgen.tech/,'SESION');
|
||
|
||
// --- SOLO_REQUISICION: solo su pestaña; el servidor le niega todo lo demás ---
|
||
const dl=ok('luis','getDatosIniciales');
|
||
assert.deepEqual([dl.soloRequisicion,dl.permisos.requisicion,dl.permisos.compras,dl.clientes.length,dl.proyectos.length,dl.tipoCambio],[true,true,false,0,0,null]);
|
||
assert.deepEqual(dl.catalogo.map(p=>({...p})),[{clave:'PAN-550',descripcion:'Panel solar 550 W',unidad:'PZA'}],'catálogo sin precios');
|
||
assert(!/Cliente Secreto|Empresa Oculta|4100|98765/.test(JSON.stringify(dl)),'sin clientes, precios ni montos');
|
||
for(const [f,args] of [['getClientes',[]],['listarCotizaciones',['']],['getCatalogo',[]],['guardarProducto',[{}]],['guardarCotizacion',[{}]],
|
||
['sugerirClavesSat',['panel']],['listarProyectos',['']],['crearProyecto',[{}]],['listarProyectosCompras',['']],['getCobranzaProyecto',[p1]]]){
|
||
falla('luis',f,args,/solo tiene acceso a Requisiciones/,'PERMISO');
|
||
falla('rosa',f,args,/solo tiene acceso a Requisiciones/,'PERMISO');
|
||
}
|
||
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 ---
|
||
const lp=ok('luis','listarProyectosRequisicion','');
|
||
assert.deepEqual(lp.map(p=>({...p})),[{folio:p2,nombre:'Planta Texas'},{folio:p1,nombre:'Techo solar'}]);
|
||
assert.deepEqual(ok('luis','listarProyectosRequisicion','texas').map(p=>p.folio),[p2]);
|
||
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 ---
|
||
const nueva=(quien,extra)=>({proyectoFolio:p1,renglones:[{descripcion:'Cable THW 10',cantidad:100,unidad:'m',notas:'rojo'},
|
||
{descripcion:'Tubo conduit 3/4',cantidad:'12',unidad:'pza'}],cotizacion:{nombre:'cot.pdf',datos:PDF},idSolicitud:'r'+(++nId),...extra});
|
||
falla('obrero','guardarRequisicion',[nueva('obrero',{cotizacion:null})],/Adjunta la cotización del proveedor/);
|
||
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',{renglones:[{descripcion:'A',cantidad:0}]})],/mayor a 0/);
|
||
falla('obrero','guardarRequisicion',[nueva('obrero',{proyectoFolio:p3})],/activos con contrato firmado/);
|
||
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');
|
||
let r=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'}));
|
||
assert.equal(r.folio,REQ('0001'));
|
||
assert.equal(ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq1'})).folio,REQ('0001'),'idempotencia');
|
||
const LC=libros[props.COMPRAS_DB_ID], H=n=>LC.pest[n].m;
|
||
assert.equal(H('Requisiciones').length,2,'una sola requisición');
|
||
let f=H('Requisiciones')[1];
|
||
assert.deepEqual([f[0],f[1],f[2],f[3],f[4],f[5],f[9]],[REQ('0001'),HOY_ISO,p1,'obrero@helgen.tech','Juan Obrero','Solicitada','']);
|
||
assert.deepEqual(H('RequisicionesRenglones').slice(1).map(x=>x.join('|')),[REQ('0001')+'|1|1|Cable THW 10|100|M|rojo',REQ('0001')+'|1|2|Tubo conduit 3/4|12|PZA|'],'sin costo');
|
||
const cot1=archivos[f[7]];
|
||
assert.equal(ruta(cot1.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1+'/Requisiciones');
|
||
assert.equal(cot1.blob.name,REQ('0001')+' — Cotización.pdf'); assert.equal(cot1.blob.mime,'application/pdf');
|
||
assert.deepEqual([cot1.acceso,cot1.padre.acceso,cot1.padre.padre.acceso,cot1.padre.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE','PRIVATE'],'restringido de arriba abajo');
|
||
// Otra de rosa (JPG) y otra de luis (SOLO_REQUISICION sin ROL_REQUISICION: también puede)
|
||
const rRosa=ok('rosa','guardarRequisicion',nueva('rosa',{proyectoFolio:p2,cotizacion:{nombre:'foto.jpg',datos:JPG}})).folio;
|
||
assert.equal(archivos[H('Requisiciones')[2][7]].blob.name,rRosa+' — Cotización.jpg');
|
||
const rLuis=ok('luis','guardarRequisicion',nueva('luis')).folio;
|
||
assert.deepEqual([rRosa,rLuis],[REQ('0002'),REQ('0003')]);
|
||
|
||
// --- El solicitante solo ve SUS requisiciones, sin montos, cliente, margen, pagos ni órdenes ---
|
||
let mias=ok('obrero','misRequisiciones');
|
||
assert.deepEqual(mias.map(x=>x.folio),[REQ('0001')]);
|
||
assert.deepEqual(ok('rosa','misRequisiciones').map(x=>x.folio),[rRosa]);
|
||
assert.deepEqual([mias[0].proyectoNombre,mias[0].estado,mias[0].renglones.length,mias[0].puedeCancelar,mias[0].puedeEditar,mias[0].puedeCerrar],
|
||
['Techo solar','Solicitada',2,true,false,false]);
|
||
assert.deepEqual(mias[0].eventos.map(e=>e.estado+':'+e.correo),['Solicitada:obrero@helgen.tech']);
|
||
falla('rosa','cancelarRequisicion',[REQ('0001'),'No es mía pero la cancelo'],/Solo quien hizo la requisición/,'PERMISO');
|
||
falla('rosa','guardarRequisicion',[{folio:REQ('0001'),renglones:[{descripcion:'X',cantidad:1}]}],/Solo quien hizo la requisición/,'PERMISO');
|
||
falla('rosa','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||
|
||
// --- Compras ve todas ---
|
||
let lr=ok('pedro','listarRequisiciones','');
|
||
assert.deepEqual(lr.map(x=>x.folio+':'+x.estado+':'+x.renglones),[rLuis+':Solicitada:2',rRosa+':Solicitada:2',REQ('0001')+':Solicitada:2']);
|
||
assert.deepEqual(ok('pedro','listarRequisiciones','rosa').map(x=>x.folio),[rRosa]);
|
||
assert.deepEqual(ok('pedro','listarRequisiciones','','Autorizada'),[]);
|
||
let vr=ok('pedro','getRequisicion',REQ('0001'));
|
||
assert.deepEqual([vr.requisicion.estado,vr.moneda,vr.renglones.length,vr.puedeAutorizar,vr.puedeRevisar,vr.puedePagar,vr.orden,vr.requisicion.cotizacionId],
|
||
['Solicitada','MXN',2,true,true,false,null,f[7]]);
|
||
|
||
// --- Devolver → corregir y reenviar (mismo folio; versión nueva de renglones; cotización nueva opcional) ---
|
||
falla('pedro','devolverRequisicion',[REQ('0001'),'no'],/motivo/);
|
||
vr=ok('pedro','devolverRequisicion',REQ('0001'),'Falta el calibre del tubo');
|
||
assert.deepEqual([vr.requisicion.estado,vr.requisicion.motivo,vr.requisicion.reviso],['Devuelta','Falta el calibre del tubo','pedro@helgen.tech']);
|
||
falla('pedro','autorizarRequisicion',[REQ('0001'),{}],/está devuelta: no se puede autorizar/);
|
||
mias=ok('obrero','misRequisiciones');
|
||
assert.deepEqual([mias[0].estado,mias[0].motivo,mias[0].puedeEditar,mias[0].puedeCancelar],['Devuelta','Falta el calibre del tubo',true,true]);
|
||
assert.deepEqual(mias[0].eventos.map(e=>e.estado+':'+e.nota),['Solicitada:','Devuelta:Falta el calibre del tubo']);
|
||
r=ok('obrero','guardarRequisicion',{folio:REQ('0001'),proyectoFolio:p2,renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',cantidad:12,unidad:'pza'}],idSolicitud:'rq-re1'});
|
||
assert.equal(r.folio,REQ('0001'));
|
||
mias=r.requisiciones;
|
||
assert.deepEqual([mias[0].estado,mias[0].motivo,mias[0].proyectoFolio,mias[0].renglones.map(x=>x.descripcion)],['Solicitada','',p1,['Tubo conduit 3/4 cédula 40']],'mismo proyecto; renglones nuevos');
|
||
assert.equal(H('RequisicionesRenglones').filter(x=>x[0]===REQ('0001')).length,3,'la versión anterior se conserva');
|
||
assert.equal(H('Requisiciones')[1][7],f[7],'sin cotización nueva se conserva la anterior');
|
||
falla('obrero','guardarRequisicion',[{folio:REQ('0001'),renglones:[{descripcion:'X',cantidad:1}]}],/está solicitada: no se puede reenviar/);
|
||
ok('pedro','devolverRequisicion',REQ('0001'),'Ahora falta la cotización firmada');
|
||
ok('obrero','guardarRequisicion',{folio:REQ('0001'),renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',cantidad:12}],cotizacion:{datos:JPG},idSolicitud:'rq-re2'});
|
||
f=H('Requisiciones')[1];
|
||
assert.equal(archivos[f[7]].blob.name,REQ('0001')+' — Cotización (3).jpg','cotización nueva; la anterior no se borra');
|
||
assert(archivos[cot1.id],'la cotización anterior sigue en Drive');
|
||
|
||
// --- Rechazar (final) y cancelar (el solicitante) ---
|
||
falla('pedro','rechazarRequisicion',[rRosa,''],/motivo/);
|
||
ok('pedro','rechazarRequisicion',rRosa,'El material no corresponde al proyecto');
|
||
falla('pedro','autorizarRequisicion',[rRosa,{}],/está rechazada: no se puede autorizar/);
|
||
falla('rosa','cancelarRequisicion',[rRosa,'Ya no la necesito'],/está rechazada: no se puede cancelar/);
|
||
assert.deepEqual(ok('rosa','misRequisiciones').map(x=>x.estado+':'+x.motivo+':'+x.puedeEditar),['Rechazada:El material no corresponde al proyecto:false']);
|
||
falla('luis','cancelarRequisicion',[rLuis,''],/motivo/);
|
||
ok('luis','cancelarRequisicion',rLuis,'Se consiguió en la obra');
|
||
falla('pedro','devolverRequisicion',[rLuis,'Revisa cantidades'],/está cancelada: no se puede devolver/);
|
||
assert.equal(H('Requisiciones').length,4,'nada se borra');
|
||
|
||
// --- Autorizar: genera la OC con los datos de compras ---
|
||
const oc=(extra)=>({proveedor:{razonSocial:'Eléctrica del Centro',rfc:'EDC200101AB1'},fechaEntrega:MANANA,condicionesPago:'Contado',
|
||
ivaTasa:0.16,renglones:[{descripcion:'Tubo conduit 3/4 cédula 40',unidad:'PZA',cantidad:12,costo:100}],idSolicitud:'o'+(++nId),...extra});
|
||
r=ok('pedro','autorizarRequisicion',REQ('0001'),oc({idSolicitud:'au1'}));
|
||
assert.deepEqual([r.folio,r.ocFolio,r.total,r.moneda,r.vista.requisicion.estado,r.vista.orden.folio,r.vista.orden.reqFolio,r.vista.puedePagar],
|
||
[REQ('0001'),OC('0001'),1392,'MXN','Autorizada',OC('0001'),REQ('0001'),true]);
|
||
assert.equal(ok('pedro','autorizarRequisicion',REQ('0001'),oc({idSolicitud:'au1'})).ocFolio,OC('0001'),'idempotencia');
|
||
assert.equal(H('OrdenesCompra').length,2);
|
||
assert.equal(H('OrdenesCompra')[1][34],REQ('0001'),'la OC guarda su requisición');
|
||
mias=ok('obrero','misRequisiciones');
|
||
assert.equal(mias[0].estado,'Autorizada');
|
||
assert(!/OC-|1392|1,392|98765|Cliente Secreto|Empresa Oculta|Eléctrica|margen|pago|comprobante/i.test(JSON.stringify(mias)),
|
||
'el solicitante no ve orden, montos, cliente, proveedor, margen ni pagos');
|
||
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está autorizada: no se puede cerrar \(solo si está pagada\)/);
|
||
|
||
// --- Pago a proveedor: comprobante obligatorio, monto = total de la OC, tipo de cambio si es otra moneda ---
|
||
const pago=(extra)=>({reqFolio:REQ('0001'),fecha:HOY_ISO,monto:1392,monedaPago:'MXN',metodo:'Transferencia',referencia:'SPEI 55',
|
||
comprobante:{nombre:'spei.pdf',datos:PDF},idSolicitud:'pg'+(++nId),...extra});
|
||
falla('pedro','registrarPagoProveedor',[pago({comprobante:null})],/Adjunta el comprobante de pago/);
|
||
falla('pedro','registrarPagoProveedor',[pago({monto:1000})],/debe ser igual al total de la orden \(\$1,392\.00 MXN\)/);
|
||
falla('pedro','registrarPagoProveedor',[pago({monto:75.25,monedaPago:'USD'})],/tipo de cambio/);
|
||
falla('pedro','registrarPagoProveedor',[pago({referencia:''})],/referencia/);
|
||
falla('pedro','registrarPagoProveedor',[pago({reqFolio:'',ocFolio:'OC-2000-0001'})],/No existe la orden/);
|
||
falla('obrero','registrarPagoProveedor',[pago()],/No tienes permiso de compras/,'PERMISO');
|
||
assert.equal(props['CONSECUTIVO_PGP_'+ANIO],undefined,'los rechazos no gastan folio');
|
||
// Desde la OC (mismo registro), en dólares con tipo de cambio: 75.25 × 18.5 = 1392.13 (dentro de ±1.00)
|
||
r=ok('pedro','registrarPagoProveedor',pago({reqFolio:'',ocFolio:OC('0001'),monto:75.25,monedaPago:'USD',tipoCambio:18.5,idSolicitud:'pg-ok'}));
|
||
assert.equal(r.folio,PGP('0001'));
|
||
assert.equal(ok('pedro','registrarPagoProveedor',pago({idSolicitud:'pg-ok'})).folio,PGP('0001'),'idempotencia');
|
||
assert.deepEqual([r.vista.requisicion.estado,r.vista.orden.estado,r.vista.pagos.length,r.vista.puedeCerrar,r.vista.puedeCancelarPago],['Pagada','Pagada',1,true,true]);
|
||
let fp=H('PagosProveedores')[1];
|
||
assert.deepEqual([fp[0],fp[1],fp[3],fp[4],fp[5],fp[6],fp[7],fp[8],fp[9],fp[10],fp[11],fp[12],fp[17]],
|
||
[PGP('0001'),HOY_ISO,OC('0001'),REQ('0001'),p1,75.25,'USD',18.5,1392.13,'MXN','Transferencia','SPEI 55','Vigente']);
|
||
const comp=archivos[fp[13]];
|
||
assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Compras/'+ANIO+'/'+p1+'/Requisiciones');
|
||
assert.equal(comp.blob.name,REQ('0001')+' — Comprobante pago.pdf'); assert.equal(comp.acceso,'PRIVATE');
|
||
falla('pedro','registrarPagoProveedor',[pago()],/está pagada: no se puede pagar/,'');
|
||
falla('pedro','cancelarOrdenCompra',[OC('0001'),'Ya no se necesita'],/ya está pagada: no se puede cancelar/);
|
||
assert.deepEqual(Object.keys(libros[props.COBRANZA_DB_ID].pest).sort(),['Bitacora','Contratos','Esquemas','Pagos'],'los pagos a proveedores no van a cobranza');
|
||
assert.equal(libros[props.COBRANZA_DB_ID].pest.Pagos.m.length<=1,true,'ni se mezclan con PAG-');
|
||
// El solicitante ve "Pagada" y la fecha, nunca el comprobante ni el monto
|
||
mias=ok('obrero','misRequisiciones');
|
||
assert.deepEqual([mias[0].estado,mias[0].puedeCerrar,mias[0].eventos[mias[0].eventos.length-1].estado],['Pagada',true,'Pagada']);
|
||
assert(!/PGP-|1392|75\.25|SPEI|drive\/file|comprobante/i.test(JSON.stringify(mias)),'sin comprobante ni monto pagado');
|
||
|
||
// --- Cancelar el pago: requisición a Autorizada y OC a Emitida; nunca se borra ---
|
||
falla('pedro','cancelarPagoProveedor',[PGP('0001'),''],/motivo/);
|
||
vr=ok('pedro','cancelarPagoProveedor',PGP('0001'),'Se capturó con el tipo de cambio equivocado');
|
||
assert.deepEqual([vr.requisicion.estado,vr.orden.estado,vr.pagos[0].estado,vr.pagos[0].motivoCancelacion,vr.puedePagar],
|
||
['Autorizada','Emitida','Cancelado','Se capturó con el tipo de cambio equivocado',true]);
|
||
falla('pedro','cancelarPagoProveedor',[PGP('0001'),'Otra vez por error'],/ya está cancelado/);
|
||
mias=ok('obrero','misRequisiciones');
|
||
assert.deepEqual([mias[0].estado,mias[0].eventos[mias[0].eventos.length-1].nota],['Autorizada','Se canceló el pago.']);
|
||
assert(!/tipo de cambio equivocado/.test(JSON.stringify(mias)),'el motivo del pago no se le muestra');
|
||
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está autorizada: no se puede cerrar/);
|
||
// Se vuelve a pagar (MXN): segundo comprobante con (2)
|
||
r=ok('pedro','registrarPagoProveedor',pago({monto:1392}));
|
||
assert.equal(r.folio,PGP('0002'));
|
||
assert.equal(archivos[H('PagosProveedores')[2][13]].blob.name,REQ('0001')+' — Comprobante pago (2).pdf');
|
||
|
||
// --- Cierre: solo el solicitante, o compras con motivo; solo si está Pagada ---
|
||
falla('rosa','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||
falla('pedro','cerrarRequisicion',[REQ('0001'),''],/Solo quien hizo la requisición/,'PERMISO');
|
||
falla('obrero','cerrarRequisicionCompras',[REQ('0001'),'Lo cierro yo'],/No tienes permiso de compras/,'PERMISO');
|
||
falla('pedro','cerrarRequisicionCompras',[REQ('0001'),''],/Escribe por qué cierras/);
|
||
falla('obrero','cerrarRequisicion',[REQ('0001'),'n'.repeat(501)],/500 caracteres/);
|
||
mias=ok('obrero','cerrarRequisicion',REQ('0001'),'Faltó 1 pieza');
|
||
assert.deepEqual([mias[0].estado,mias[0].notasRecepcion,mias[0].puedeCerrar,mias[0].eventos[mias[0].eventos.length-1].nota],['Cerrada','Faltó 1 pieza',false,'Material recibido · Faltó 1 pieza']);
|
||
let fo=H('OrdenesCompra')[1];
|
||
assert.deepEqual([fo[28],fo[29],fo[30]],['Recibida',HOY_ISO,'obrero@helgen.tech']);
|
||
falla('obrero','cerrarRequisicion',[REQ('0001'),''],/está cerrada: no se puede cerrar/);
|
||
falla('pedro','cancelarPagoProveedor',[PGP('0002'),'Se pagó doble por error'],/ya está cerrada \(material recibido\): el pago no se puede cancelar/);
|
||
falla('pedro','cancelarOrdenCompra',[OC('0001'),'Ya no se necesita'],/ya se recibió/);
|
||
|
||
// Compras cierra en lugar del solicitante (con motivo); la OC queda recibida con el correo de compras
|
||
ok('pedro','guardarRequisicion',nueva('pedro',{proyectoFolio:p2,idSolicitud:'rq-p'}));
|
||
const R5=REQ('0004');
|
||
falla('pedro','cerrarRequisicionCompras',[R5,'El trabajador ya no está'],/está solicitada: no se puede cerrar/);
|
||
r=ok('pedro','autorizarRequisicion',R5,oc({moneda:'USD',ivaTasa:0,renglones:[{descripcion:'Cable',cantidad:1,costo:50}]}));
|
||
assert.deepEqual([r.ocFolio,r.total,r.moneda],[OC('0002'),50,'USD']);
|
||
ok('pedro','registrarPagoProveedor',{reqFolio:R5,fecha:HOY_ISO,monto:925,monedaPago:'MXN',tipoCambio:18.5,metodo:'Efectivo',comprobante:{datos:JPG}});
|
||
vr=ok('efrain','cerrarRequisicionCompras',R5,'El trabajador ya no está en la obra','Llegó completo');
|
||
assert.deepEqual([vr.requisicion.estado,vr.orden.estado,vr.orden.recibio,vr.requisicion.notasRecepcion],['Cerrada','Recibida','efrain@helgen.tech','Llegó completo']);
|
||
assert(/^Cerrada por compras: El trabajador ya no está en la obra · Llegó completo$/.test(vr.eventos[vr.eventos.length-1].nota));
|
||
const fp3=H('PagosProveedores')[3]; assert.deepEqual([fp3[0],fp3[6],fp3[7],fp3[8],fp3[9],fp3[10],fp3[12]],[PGP('0003'),925,'MXN',18.5,50,'USD','']);
|
||
|
||
// --- Cancelar la OC de una requisición autorizada: vuelve a Solicitada y se autoriza de nuevo con otra OC ---
|
||
const R6=ok('obrero','guardarRequisicion',nueva('obrero',{idSolicitud:'rq-6'})).folio;
|
||
r=ok('pedro','autorizarRequisicion',R6,oc());
|
||
ok('pedro','cancelarOrdenCompra',r.ocFolio,'El proveedor subió el precio');
|
||
vr=ok('pedro','getRequisicion',R6);
|
||
assert.deepEqual([vr.requisicion.estado,vr.requisicion.ocFolio,vr.puedeAutorizar],['Solicitada','',true]);
|
||
assert.equal(ok('obrero','misRequisiciones')[0].estado,'Solicitada');
|
||
r=ok('pedro','autorizarRequisicion',R6,oc({proveedor:{razonSocial:'Otro proveedor'}}));
|
||
assert.equal(r.ocFolio,OC('0004'),'otra OC con su folio normal');
|
||
|
||
// --- Folios y bitácora ---
|
||
assert.deepEqual([props['CONSECUTIVO_REQ_'+ANIO],props['CONSECUTIVO_PGP_'+ANIO],props['CONSECUTIVO_OC_'+ANIO]],['5','3','4']);
|
||
const acciones=H('Bitacora').slice(1).map(b=>b[2]);
|
||
for(const a of ['Solicitar requisición','Devolver requisición','Reenviar requisición','Rechazar requisición','Cancelar requisición',
|
||
'Autorizar requisición','Registrar pago a proveedor','Cancelar pago a proveedor','Cerrar requisición','Requisición a Solicitada'])
|
||
assert(acciones.includes(a),'bitácora: '+a);
|
||
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(!JSON.stringify(db.pest).includes('REQ-')&&!JSON.stringify(db.pest).includes('PGP-'));
|
||
console.log('OK requisiciones');
|