Cobranza: carpeta, libro, subcarpetas y comprobantes en acceso restringido

Workspace de helgen.tech comparte como lector con todo el dominio lo que se crea:
la carpeta y el libro de cobranza nacieron visibles para cualquier vendedor.
privado_() los deja en Restringido (conserva a tania@ y leo@) y prepararCobranza()
repara los que ya existen. Prueba: el simulacro de Drive comparte lo nuevo con el dominio.
CLAUDE.md: IDs de la carpeta y el libro de cobranza reales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Efrain (Helgen) 2026-10-20 17:47:45 -06:00
parent 11708f2f1f
commit 768554557f
3 changed files with 42 additions and 16 deletions

View File

@ -33,7 +33,7 @@ autorrelleno, y claves SAT internas por producto para contabilidad (con sugerenc
| Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` | | Catálogo "Catálogo Helgen" (`CATALOGO_ID`) | `11Rr0G7ZHE8PqXWsw9OpPPfGLQx8Yz7g8h1wkjJY2ahQ` |
| Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) | | Carpeta PDF "Cotizaciones Helgen (PDF)" (`FOLDER_ID`) | `1CDN1LyOlA1L6z5dvBp49tlqu0fDoxdX8` (subcarpeta `2026` = `1X6m0nexpc3TzJd9XWBmGOAeZuiJE3Pig`) |
| "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) | | "Catálogos SAT (CFDI 4.0)" (`SAT_ID`) | `1B2t9tXuEj1i9JO9ybqsuH4PN1K47CqMZgoEBzd-Dmc8` (subcarpeta "Catálogos SAT" `15OYcUp41onVJoW7--b3gnLZY8ymvYbUZ`) |
| Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | Fuera de la carpeta maestra, en Mi unidad. Se crea con `prepararCobranza()` (ID pendiente: anotarlo aquí al crearla en la API real). Lectores: tania@ y leo@ (§6.11) | | Carpeta "Cotizador Helgen — Cobranza" (`COBRANZA_FOLDER_ID`) | `1A9uoRdhFdWOBacWCxx4Ktw1d9iQH7JRc` (creada el 20/10/2026 con `prepararCobranza()`; fuera de la carpeta maestra, en Mi unidad). Libro "Cotizador Helgen — Cobranza (datos)" (`COBRANZA_DB_ID`) `1uk9FNzTkQPjvWJw2R5wb8HXZl9c8ldYvyB64c8yjHL0`. Lectores: tania@ y leo@ (§6.11) |
| Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) | | Respaldos de la migración | Carpeta "Respaldos 2026-10-19" `1hxx0tgh4cLZMqoyf-eT8l14UAde7Y-L1` (copias de base de datos y catálogo antes del cambio) |
| Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) | | Respaldos antes de Proyectos | Carpeta "Respaldos 2026-10-20" `1rFGY_k_YRRo243p3xL2RpibQ9jkEo634`: Base de datos `1PBQvmwI412IMyJb74qBWN5ZcyhSuxK3xGU9cV5KPxTE`, Catálogo `1GxBpAvQxVNJyFJSLnaY9umA5L2qf4dqX36S4uehHUNc` (antes de la pestaña Proyectos y la columna T) |
| Respaldos antes de Cobranza | Carpeta "Respaldos 2026-10-20 (antes de Cobranza)" `1C_yGbX0Gk_cZBwegdswPFvuzCVDdyTLF` (en la carpeta maestra): Base de datos `14C3d7w2ouaygtCGi0BP1QocGiqG4gf5Umh9O4KHgdow`, Catálogo `140_1sfwXF9u_aL1egDPYSbsE0rMwELGPZ5rkGVdzQnc` (antes de las columnas U:V, la columna I del catálogo y la pestaña "Tipo de cambio"). Verificadas contra los originales | | Respaldos antes de Cobranza | Carpeta "Respaldos 2026-10-20 (antes de Cobranza)" `1C_yGbX0Gk_cZBwegdswPFvuzCVDdyTLF` (en la carpeta maestra): Base de datos `14C3d7w2ouaygtCGi0BP1QocGiqG4gf5Umh9O4KHgdow`, Catálogo `140_1sfwXF9u_aL1egDPYSbsE0rMwELGPZ5rkGVdzQnc` (antes de las columnas U:V, la columna I del catálogo y la pestaña "Tipo de cambio"). Verificadas contra los originales |
@ -381,6 +381,11 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A
- **Datos en un libro aparte** dentro de la carpeta "Cotizador Helgen — Cobranza", fuera de la carpeta maestra: la Base de - **Datos en un libro aparte** dentro de la carpeta "Cotizador Helgen — Cobranza", fuera de la carpeta maestra: la Base de
datos y la carpeta maestra están compartidas como **lector con todo el dominio helgen.tech** (revisado el 20/10/2026), así datos y la carpeta maestra están compartidas como **lector con todo el dominio helgen.tech** (revisado el 20/10/2026), así
que una pestaña Pagos ahí la vería cualquier vendedor. **Nunca poner datos de cobranza en la Base de datos.** que una pestaña Pagos ahí la vería cualquier vendedor. **Nunca poner datos de cobranza en la Base de datos.**
- **Workspace comparte con todo helgen.tech lo que se crea** (pasó el 20/10/2026: la carpeta y el libro de cobranza nacieron
con "helgen.tech: lector" aunque el código no compartía nada). Por eso todo lo de cobranza (carpeta, libro, subcarpetas de
año y proyecto, comprobantes) pasa por `privado_()`, que lo deja en "Restringido" y conserva a las personas agregadas una por
una. `prepararCobranza()` repara carpeta y libro si alguien los vuelve a compartir. Si se crean archivos en otra área privada,
hacer lo mismo.
- **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza; - **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza;
`ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de `ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de
cobranza (solo `permisos` en falso para ocultar la pestaña). cobranza (solo `permisos` en falso para ocultar la pestaña).

33
Code.js
View File

@ -1049,12 +1049,21 @@ function exigirCobranza_() {
if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.'); if (!esCobranza_()) throw errorPermiso_('No tienes permiso de cobranza.');
} }
/** Carpeta de cobranza. Se crea sola en Mi unidad (sin compartir). Si se borró, error: no se crea otra a escondidas. */ /**
* Deja un archivo o carpeta de cobranza en "Restringido": quita el acceso por enlace o de todo el dominio y conserva a
* las personas agregadas una por una (tania@, leo@). Workspace comparte con todo helgen.tech lo que se crea (§6.11).
*/
function privado_(item) {
if (item.getSharingAccess() !== DriveApp.Access.PRIVATE) item.setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.NONE);
return item;
}
/** Carpeta de cobranza. Se crea sola en Mi unidad (restringida). Si se borró, error: no se crea otra a escondidas. */
function carpetaCobranza_() { function carpetaCobranza_() {
const props = PropertiesService.getScriptProperties(); const props = PropertiesService.getScriptProperties();
const id = props.getProperty('COBRANZA_FOLDER_ID'); const id = props.getProperty('COBRANZA_FOLDER_ID');
if (!id) { if (!id) {
const carpeta = DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA); const carpeta = privado_(DriveApp.createFolder(NOMBRE_CARPETA_COBRANZA));
props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId()); props.setProperty('COBRANZA_FOLDER_ID', carpeta.getId());
return carpeta; return carpeta;
} }
@ -1063,7 +1072,7 @@ function carpetaCobranza_() {
if (!carpeta || carpeta.isTrashed()) { if (!carpeta || carpeta.isTrashed()) {
throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.'); throw new Error('No se encontró la carpeta de cobranza (COBRANZA_FOLDER_ID). Revisa la Papelera de Drive.');
} }
return carpeta; return privado_(carpeta);
} }
/** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */ /** Libro de cobranza; la primera vez lo crea dentro de la carpeta de cobranza (bajo candado). */
@ -1098,7 +1107,7 @@ function abrirLibroCobranza_(id) {
function crearLibroCobranza_() { function crearLibroCobranza_() {
const carpeta = carpetaCobranza_(); const carpeta = carpetaCobranza_();
const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA); const ss = SpreadsheetApp.create(NOMBRE_LIBRO_COBRANZA);
DriveApp.getFileById(ss.getId()).moveTo(carpeta); privado_(DriveApp.getFileById(ss.getId())).moveTo(carpeta);
Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre)); Object.keys(HOJAS_COBRANZA).forEach(nombre => hojaCobranza_(ss, nombre));
const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1'); const sobrante = ss.getSheetByName('Hoja 1') || ss.getSheetByName('Sheet1');
if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante); if (sobrante && ss.getSheets().length > 1) ss.deleteSheet(sobrante);
@ -1466,9 +1475,9 @@ function registrarPago(p) {
const hojaPagos = d.hojas.pagos; const hojaPagos = d.hojas.pagos;
const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_'); const folio = siguienteFolio_(PREFIJO_PAGO, hojaPagos, 'CONSECUTIVO_PAG_');
const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4); const anio = String(proyecto.folio).split('-')[1] || hoyIso_().slice(0, 4);
const carpeta = subcarpeta_(subcarpeta_(carpetaCobranza_(), anio), proyecto.folio); const carpeta = privado_(subcarpeta_(privado_(subcarpeta_(carpetaCobranza_(), anio)), proyecto.folio));
const archivo = carpeta.createFile(Utilities.newBlob(bytes, tipo.mime, const archivo = privado_(carpeta.createFile(Utilities.newBlob(bytes, tipo.mime,
nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext))); nombreComprobante(folio, pago.metodo, pago.referencia, tipo.ext))));
const t = textoCelda; const t = textoCelda;
hojaPagos.appendRow([ hojaPagos.appendRow([
@ -1511,13 +1520,15 @@ function cancelarPago(folioPago, motivo) {
} }
/** /**
* Correr UNA vez desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad, sin compartir) y su * Correr desde el editor: crea la carpeta "Cotizador Helgen — Cobranza" (en Mi unidad) y su libro, y deja los dos en
* libro. También sirve para autorizar permisos antes de publicar (§6.8). No comparte nada. * "Restringido" (también si ya existían). Sirve para autorizar permisos antes de publicar (§6.8). No comparte nada.
*/ */
function prepararCobranza() { function prepararCobranza() {
const libro = libroCobranza_(); const libro = libroCobranza_();
Logger.log('Carpeta de cobranza: ' + carpetaCobranza_().getUrl()); const archivoLibro = privado_(DriveApp.getFileById(libro.getId()));
Logger.log('Libro de cobranza: ' + libro.getUrl()); const carpeta = carpetaCobranza_();
Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (acceso: ' + carpeta.getSharingAccess() + ')');
Logger.log('Libro de cobranza: ' + libro.getUrl() + ' (acceso: ' + archivoLibro.getSharingAccess() + ')');
Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)')); Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)'));
} }

View File

@ -34,11 +34,13 @@ function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:()
// ---- Drive simulado ---- // ---- Drive simulado ----
let nId=0; const carpetas={}, archivos={}; let nId=0; const carpetas={}, archivos={};
function carpeta(nombre,padre){const f={id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false, // Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN')
const compartible=o=>Object.assign(o,{acceso:'DOMAIN',getSharingAccess:()=>o.acceso,setSharing:(a,p)=>{o.acceso=a;o.permiso=p}});
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, 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++]}}, 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}, createFolder:n=>{const h=carpeta(n,f);f.hijos.push(h);return h},
createFile:b=>{const id='A'+(++nId);const a={id,blob:b,padre:f};archivos[id]=a;f.archivos.push(a);return{getId:()=>id,getUrl:()=>'https://drive/file/'+id}}}; 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} carpetas[f.id]=f;return f}
const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)'); const miUnidad=carpeta('Mi unidad',null), maestra=miUnidad.createFolder('Cotizador Helgen'), pdfRaiz=maestra.createFolder('Cotizaciones Helgen (PDF)');
const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre; const ruta=f=>f.padre?ruta(f.padre)+'/'+f.nombre:f.nombre;
@ -71,12 +73,14 @@ const ctx={console:{...console,warn(){}},Date,JSON,
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}})},
SpreadsheetApp:{flush(){},openById:id=>{if(!libros[id])throw new Error('No encontrado');return libros[id]}, 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]={id:L.id,padre:miUnidad};return L}, 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}}, 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]}, DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]},
createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad, createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad,
getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}}, getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}},
moveTo:f=>{archivos[id].padre=f}})}, moveTo:f=>{archivos[id].padre=f},getSharingAccess:()=>archivos[id].getSharingAccess(),
setSharing:(a,p)=>archivos[id].setSharing(a,p),getId:()=>id}),
Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}},
}; };
vm.createContext(ctx); vm.runInContext(src,ctx); 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 post=(quien,funcion,...args)=>JSON.parse(ctx.doPost({postData:{contents:JSON.stringify({funcion,args,token:quien})}}).t);
@ -169,6 +173,8 @@ assert.equal(ruta(carpCob),'Mi unidad/Cotizador Helgen — Cobranza');
const LC=libros[props.COBRANZA_DB_ID]; const LC=libros[props.COBRANZA_DB_ID];
assert.equal(LC.nombre,'Cotizador Helgen — Cobranza (datos)'); assert.equal(archivos[LC.id].padre,carpCob); assert.equal(LC.nombre,'Cotizador Helgen — Cobranza (datos)'); assert.equal(archivos[LC.id].padre,carpCob);
assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','Contratos','Esquemas','Pagos']); assert.deepEqual(Object.keys(LC.pest).sort(),['Bitacora','Contratos','Esquemas','Pagos']);
// Restringidos aunque Workspace comparta lo nuevo con todo el dominio
assert.equal(carpCob.acceso,'PRIVATE','carpeta de cobranza restringida'); assert.equal(archivos[LC.id].acceso,'PRIVATE','libro restringido');
const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1); const H=n=>LC.pest[n].m, bit=()=>H('Bitacora').slice(1);
let v=ok('tania','getCobranzaProyecto',p1.toLowerCase()); let v=ok('tania','getCobranzaProyecto',p1.toLowerCase());
@ -264,6 +270,7 @@ const comp=archivos[fp[14]];
assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p1); assert.equal(ruta(comp.padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p1);
assert.equal(comp.blob.name,r.folio+' — Transferencia SPEI 123.pdf'); assert.equal(comp.blob.mime,'application/pdf'); assert.equal(comp.blob.name,r.folio+' — Transferencia SPEI 123.pdf'); assert.equal(comp.blob.mime,'application/pdf');
assert.deepEqual(Buffer.from(comp.blob.bytes),PDF); assert.equal(fp[15],r.url); assert.deepEqual(Buffer.from(comp.blob.bytes),PDF); assert.equal(fp[15],r.url);
assert.deepEqual([comp.acceso,comp.padre.acceso,comp.padre.padre.acceso],['PRIVATE','PRIVATE','PRIVATE'],'comprobante y subcarpetas restringidos');
assert(!Object.values(carpetas).some(f=>f.nombre==='Pagos')&&vivos(maestra).every(f=>!/Cobranza/.test(f.nombre)),'nada de cobranza en la carpeta maestra'); assert(!Object.values(carpetas).some(f=>f.nombre==='Pagos')&&vivos(maestra).every(f=>!/Cobranza/.test(f.nombre)),'nada de cobranza en la carpeta maestra');
function vivos(f){return f.hijos.filter(h=>!h.trashed)} function vivos(f){return f.hijos.filter(h=>!h.trashed)}
// Idempotencia: el mismo envío devuelve el mismo folio sin duplicar // Idempotencia: el mismo envío devuelve el mismo folio sin duplicar
@ -316,6 +323,9 @@ assert.deepEqual([fu[4],fu[5],fu[6],fu[7],fu[8]],[18500,'MXN',18.5,1000,'USD']);
assert.deepEqual([r.vista.resumen.moneda,r.vista.resumen.pagado,r.vista.resumen.saldo],['USD',1000,0]); assert.deepEqual([r.vista.resumen.moneda,r.vista.resumen.pagado,r.vista.resumen.saldo],['USD',1000,0]);
falla('efrain','registrarPago',[pago({proyectoFolio:p2,monto:1.01,monedaPago:'USD'})],/excede el saldo/); falla('efrain','registrarPago',[pago({proyectoFolio:p2,monto:1.01,monedaPago:'USD'})],/excede el saldo/);
assert.equal(ruta(archivos[fu[14]].padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p2); assert.equal(ruta(archivos[fu[14]].padre),'Mi unidad/Cotizador Helgen — Cobranza/'+ANIO+'/'+p2);
// Si alguien vuelve a compartir carpeta o libro con el dominio, prepararCobranza() (y el siguiente pago) los restringe
carpCob.acceso='DOMAIN'; archivos[LC.id].acceso='DOMAIN'; ctx.prepararCobranza();
assert.deepEqual([carpCob.acceso,archivos[LC.id].acceso],['PRIVATE','PRIVATE'],'prepararCobranza repara el acceso');
// Los datos de cobranza nunca van en la Base de datos (todo el dominio la puede leer) // Los datos de cobranza nunca van en la Base de datos (todo el dominio la puede leer)
assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos']); assert.deepEqual(Object.keys(db.pest).sort(),['Config','Cotizaciones','Proyectos']);
assert(!JSON.stringify(db.pest).includes('PAG-')); assert(!JSON.stringify(db.pest).includes('PAG-'));