diff --git a/Code.js b/Code.js index 79ca115..716a099 100644 --- a/Code.js +++ b/Code.js @@ -1135,14 +1135,14 @@ function contratoDesdeFila_(r, fila) { return { proyectoFolio: String(r[0]), cotizacion: String(r[1]), marca: marcaDeTexto_(r[2]), moneda: String(r[3] || 'MXN'), subtotal: Number(r[4]) || 0, iva: Number(r[5]) || 0, total: Number(r[6]) || 0, estado: String(r[7] || ''), - aceptadaPor: String(r[8] || ''), fechaAceptacion: fechaTexto_(r[9]), fechaFirma: String(r[10] || ''), + aceptadaPor: String(r[8] || ''), fechaAceptacion: fechaTexto_(r[9]), fechaFirma: fechaIso_(r[10]), firmadoPor: String(r[11] || ''), fila: fila, }; } function pagoDesdeFila_(r, fila) { return { - folio: String(r[0]), fecha: String(r[1] || ''), fechaRegistro: fechaTexto_(r[2]), proyectoFolio: String(r[3]), + folio: String(r[0]), fecha: fechaIso_(r[1]), fechaRegistro: fechaTexto_(r[2]), proyectoFolio: String(r[3]), monto: Number(r[4]) || 0, monedaPago: String(r[5] || ''), tipoCambio: r[6] === '' ? '' : Number(r[6]), montoAplicado: Number(r[7]) || 0, monedaProyecto: String(r[8] || ''), metodo: String(r[9] || ''), referencia: String(r[10] || ''), etapaOrden: Number(r[11]) || 1, etapa: String(r[12] || ''), @@ -1155,6 +1155,15 @@ function fechaTexto_(v) { return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'dd/MM/yyyy HH:mm') : String(v || ''); } +/** + * Fecha sin hora (pago, firma) como "AAAA-MM-DD". Se escriben con apóstrofo (textoFecha_) para que Sheets no las + * convierta en fecha (appendRow lo hace aunque la columna sea texto); las filas guardadas antes vienen como Date. + */ +function fechaIso_(v) { + return v instanceof Date ? Utilities.formatDate(v, 'America/Mexico_City', 'yyyy-MM-dd') : String(v || ''); +} +const textoFecha_ = iso => iso ? "'" + iso : ''; + const hoyIso_ = () => Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd'); /** Todo lo de cobranza de un proyecto, leído del libro. */ @@ -1380,8 +1389,9 @@ function firmarContrato(proyectoFolio, fecha) { formatoMoneda(r.contratado) + ').'); } escribirEsquema_(d.hojas.esquemas, proyecto.folio, etapas, c.total); - d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_FIRMADO, c.aceptadaPor, c.fechaAceptacion, fechaFirma, - textoCelda(VENDEDOR_.email), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]); + d.hojas.contratos.getRange(c.fila, 8).setValue(ESTADO_FIRMADO); + d.hojas.contratos.getRange(c.fila, 11, 1, 3).setValues([[textoFecha_(fechaFirma), textoCelda(VENDEDOR_.email), + Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]); bitacora_(libro, 'Contrato firmado', proyecto.folio, 'Fecha de firma ' + fechaFirma + ' · ' + c.cotizacion + ' · ' + formatoMoneda(c.total) + ' ' + c.moneda + ' · ' + etapas.map(e => e.etapa + ' ' + e.porcentaje + '%').join(' · ')); SpreadsheetApp.flush(); @@ -1402,7 +1412,8 @@ function desbloquearContrato(proyectoFolio, motivo) { const d = datosCobranza_(libro, proyecto.folio); const c = d.contrato; if (!c || c.estado !== ESTADO_FIRMADO) throw new Error('El contrato de ' + proyecto.folio + ' no está firmado.'); - d.hojas.contratos.getRange(c.fila, 8, 1, 6).setValues([[ESTADO_ACEPTADA, c.aceptadaPor, c.fechaAceptacion, '', '', + d.hojas.contratos.getRange(c.fila, 8).setValue(ESTADO_ACEPTADA); + d.hojas.contratos.getRange(c.fila, 11, 1, 3).setValues([['', '', Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm')]]); bitacora_(libro, 'Desbloquear contrato', proyecto.folio, 'Estaba firmado el ' + c.fechaFirma + ' por ' + c.firmadoPor + ' · ' + c.cotizacion, m); @@ -1461,7 +1472,7 @@ function registrarPago(p) { const t = textoCelda; hojaPagos.appendRow([ - folio, pago.fecha, Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), proyecto.folio, + folio, textoFecha_(pago.fecha), Utilities.formatDate(new Date(), 'America/Mexico_City', 'yyyy-MM-dd HH:mm:ss'), proyecto.folio, pago.monto, pago.monedaPago, pago.tipoCambio, pago.montoAplicado, c.moneda, pago.metodo, t(pago.referencia), pago.etapaOrden, t(d.etapas[pago.etapaOrden - 1].etapa), JSON.stringify(aplicacion), archivo.getId(), archivo.getUrl(), t(VENDEDOR_.email), t([VENDEDOR_.nombre, VENDEDOR_.apellido].filter(Boolean).join(' ')), 'Vigente', '', '', '', diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index 3131290..90a61b0 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -11,13 +11,18 @@ 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),deleteRow:n=>m.splice(n-1,1), + 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{m[r-1+i]=m[r-1+i]||[];x.forEach((y,j)=>m[r-1+i][c-1+j]=y)});return this}, + 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={}; @@ -247,6 +252,10 @@ assert.equal(r.folio,'PAG-'+ANIO+'-0001'); assert.deepEqual(r.vista.resumen.etapas.map(e=>[e.pagado,e.saldo]),[[696,0],[104,244],[0,116]]); assert.deepEqual([r.vista.resumen.pagado,r.vista.resumen.saldo],[800,360]); const fp=H('Pagos')[1]; +// La fecha del pago se guarda como texto AAAA-MM-DD (no la convierte Sheets) y así se muestra +assert.equal(r.vista.pagos[0].fecha,HOY_ISO,'fecha del pago legible'); +assert.equal(r.vista.contrato.fechaFirma,HOY_ISO,'fecha de firma legible'); +assert(/^\d\d\/\d\d\/\d{4} \d\d:\d\d$/.test(r.vista.contrato.fechaAceptacion),'fecha de aceptación legible: '+r.vista.contrato.fechaAceptacion); assert.deepEqual([fp[0],fp[1],fp[3],fp[4],fp[5],fp[7],fp[8],fp[9],fp[10],fp[11],fp[12],fp[16],fp[18]], [r.folio,HOY_ISO,p1,800,'MXN',800,'MXN','Transferencia','SPEI 123',1,'Anticipo','tania@helgen.tech','Vigente']); assert.deepEqual(JSON.parse(fp[13]).map(a=>a.etapa+':'+a.monto),['Anticipo:696','Instalación:104']); @@ -282,7 +291,7 @@ v=ok('efrain','desbloquearContrato',p1,'Se firmó con la cotización equivocada' assert.deepEqual([v.contrato.estado,v.contrato.fechaFirma],['Aceptada','']); assert.equal(v.pagos.length,2,'los pagos se conservan'); const ub=bit().find(b=>b[2]==='Desbloquear contrato'); assert.deepEqual([ub[1],ub[3],ub[5]],['efrain@helgen.tech',p1,'Se firmó con la cotización equivocada']); -assert(ub[4].includes('tania@helgen.tech')&&ub[4].includes(HOY_ISO)&&/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(ub[0]),'quién, cuándo y qué estaba'); +assert(ub[4].includes('tania@helgen.tech')&&ub[4].includes('firmado el '+HOY_ISO)&&ub[0] instanceof Date,'quién, cuándo (fecha de Sheets) y qué estaba'); falla('tania','registrarPago',[pago()],/contrato firmado/,undefined); // Con pagos en MXN no se puede aceptar una cotización en USD (nunca mezclar monedas) falla('tania','marcarAceptada',[p1,cU],/pagos en MXN/);