/** * @OnlyCurrentDoc * (Esta línea limita el permiso de Hojas de cálculo a ESTA hoja, no a todas tus hojas.) */ /** * SEGUIMIENTO AUTOMÁTICO DE PRESUPUESTOS * Plantilla gratuita de "Autónomo Automático" — versión 1.0 * * Qué hace: * - Revisa cada día tu hoja "Presupuestos". * - Si un presupuesto lleva X días "Enviado" sin respuesta, prepara un email * de seguimiento en Gmail (por defecto como BORRADOR: tú decides si lo envías). * - Hace hasta 3 seguimientos y después marca el presupuesto como "Sin respuesta". * - Los lunes te manda un resumen con lo que tienes pendiente de cobrar/cerrar. * * Coste: 0 €. Funciona con una cuenta gratuita de Google (Sheets + Gmail + Apps Script). * No usa ninguna API de pago ni envía tus datos a terceros. * * Instalación: ver INSTALAR.md */ const HOJA_PRESUPUESTOS = 'Presupuestos'; const HOJA_AJUSTES = 'Ajustes'; const HOJA_REGISTRO = 'Registro'; // Posición de cada columna en la hoja "Presupuestos" (empieza en 0 = columna A) const COL = { ID: 0, // A FECHA_ENVIO: 1, // B CLIENTE: 2, // C EMAIL: 3, // D CONCEPTO: 4, // E IMPORTE: 5, // F ESTADO: 6, // G SEGUIMIENTOS: 7, // H ULTIMO: 8, // I PROXIMO: 9, // J NOTAS: 10 // K }; const CABECERAS = [ 'ID', 'Fecha envío', 'Cliente', 'Email', 'Concepto', 'Importe (€)', 'Estado', 'Seguimientos hechos', 'Último seguimiento', 'Próxima acción', 'Notas' ]; const ESTADOS = { ENVIADO: 'Enviado', ACEPTADO: 'Aceptado', RECHAZADO: 'Rechazado', SIN_RESPUESTA: 'Sin respuesta' }; const MAX_SEGUIMIENTOS = 3; const AJUSTES_POR_DEFECTO = { tu_nombre: 'Tu nombre', tu_negocio: 'Tu negocio', modo: 'BORRADOR', // BORRADOR = crea borradores en Gmail. ENVIAR = los envía directamente. dias_seguimiento_1: 3, dias_seguimiento_2: 7, dias_seguimiento_3: 14, dias_cierre: 7, email_resumen: '', // vacío = tu propio email asunto_1: '¿Pudiste revisar el presupuesto de {concepto}?', cuerpo_1: 'Hola {cliente},\n\n' + 'Te escribo por el presupuesto de {concepto} que te envié el {fecha_envio}. ' + '¿Has podido revisarlo? Si tienes cualquier duda o quieres ajustar algo, dímelo y lo vemos.\n\n' + 'Un saludo,\n{tu_nombre}\n{tu_negocio}', asunto_2: 'Presupuesto de {concepto}: ¿lo ajustamos?', cuerpo_2: 'Hola {cliente},\n\n' + 'Retomo el presupuesto de {concepto} ({importe}). Si el precio, los plazos o el alcance ' + 'no encajan del todo, puedo prepararte una alternativa. ¿Te viene bien que lo hablemos 10 minutos esta semana?\n\n' + 'Un saludo,\n{tu_nombre}\n{tu_negocio}', asunto_3: '¿Cierro el presupuesto de {concepto}?', cuerpo_3: 'Hola {cliente},\n\n' + 'Para no insistirte más: si ahora no es buen momento, lo entiendo perfectamente y cierro el presupuesto de {concepto}. ' + 'Si más adelante quieres retomarlo, solo tienes que responder a este correo.\n\n' + 'Gracias por tu tiempo,\n{tu_nombre}\n{tu_negocio}' }; const DESCRIPCION_AJUSTES = { tu_nombre: 'Firma de los emails', tu_negocio: 'Nombre de tu negocio (aparece en la firma)', modo: 'BORRADOR (recomendado) o ENVIAR', dias_seguimiento_1: 'Días desde el envío para el 1.er seguimiento', dias_seguimiento_2: 'Días desde el envío para el 2.º seguimiento', dias_seguimiento_3: 'Días desde el envío para el 3.er seguimiento', dias_cierre: 'Días tras el 3.er seguimiento para marcarlo "Sin respuesta"', email_resumen: 'Dónde recibir el resumen semanal (vacío = tu email)', asunto_1: 'Asunto del 1.er seguimiento', cuerpo_1: 'Texto del 1.er seguimiento', asunto_2: 'Asunto del 2.º seguimiento', cuerpo_2: 'Texto del 2.º seguimiento', asunto_3: 'Asunto del 3.er seguimiento', cuerpo_3: 'Texto del 3.er seguimiento' }; /* ========================================================================= * MENÚ * ========================================================================= */ function onOpen() { SpreadsheetApp.getUi() .createMenu('🤖 Automático') .addItem('1. Configurar hojas (solo la primera vez)', 'configurarHojas') .addItem('2. Revisar presupuestos ahora', 'revisarPresupuestosConAviso') .addSeparator() .addItem('Activar revisión diaria + resumen semanal', 'activarAutomatico') .addItem('Desactivar automático', 'desactivarAutomatico') .addSeparator() .addItem('Enviarme el resumen ahora', 'enviarResumenConAviso') .addToUi(); } /* ========================================================================= * CONFIGURACIÓN INICIAL * ========================================================================= */ function configurarHojas() { const ss = SpreadsheetApp.getActive(); // Hoja Presupuestos let hoja = ss.getSheetByName(HOJA_PRESUPUESTOS); if (!hoja) hoja = ss.insertSheet(HOJA_PRESUPUESTOS, 0); hoja.getRange(1, 1, 1, CABECERAS.length).setValues([CABECERAS]) .setFontWeight('bold').setBackground('#14213D').setFontColor('#FFFFFF'); hoja.setFrozenRows(1); hoja.getRange('B2:B').setNumberFormat('dd/mm/yyyy'); hoja.getRange('F2:F').setNumberFormat('#,##0.00 €'); hoja.getRange('I2:J').setNumberFormat('dd/mm/yyyy'); const reglaEstado = SpreadsheetApp.newDataValidation() .requireValueInList([ESTADOS.ENVIADO, ESTADOS.ACEPTADO, ESTADOS.RECHAZADO, ESTADOS.SIN_RESPUESTA], true) .setAllowInvalid(false).build(); hoja.getRange('G2:G').setDataValidation(reglaEstado); aplicarColores_(hoja); // Ejemplos (solo si la hoja está vacía). Datos ficticios para probar. if (hoja.getLastRow() < 2) { const hoy = new Date(); const haceDias = function (n) { const d = new Date(hoy); d.setDate(d.getDate() - n); return d; }; const tuEmail = Session.getActiveUser().getEmail() || 'tu-email@ejemplo.com'; hoja.getRange(2, 1, 3, CABECERAS.length).setValues([ ['P-001', haceDias(4), 'Cliente de prueba 1', tuEmail, 'reforma de baño', 3200, ESTADOS.ENVIADO, 0, '', '', 'EJEMPLO: bórralo cuando pruebes'], ['P-002', haceDias(1), 'Cliente de prueba 2', tuEmail, 'diseño de logotipo', 450, ESTADOS.ENVIADO, 0, '', '', 'EJEMPLO'], ['P-003', haceDias(10), 'Cliente de prueba 3', tuEmail, 'mantenimiento web anual', 600, ESTADOS.ACEPTADO, 1, '', '', 'EJEMPLO'] ]); } hoja.autoResizeColumns(1, CABECERAS.length); // Hoja Ajustes let ajustes = ss.getSheetByName(HOJA_AJUSTES); if (!ajustes) { ajustes = ss.insertSheet(HOJA_AJUSTES); const filas = [['Clave', 'Valor', 'Qué es']]; Object.keys(AJUSTES_POR_DEFECTO).forEach(function (k) { filas.push([k, AJUSTES_POR_DEFECTO[k], DESCRIPCION_AJUSTES[k] || '']); }); ajustes.getRange(1, 1, filas.length, 3).setValues(filas); ajustes.getRange(1, 1, 1, 3).setFontWeight('bold').setBackground('#14213D').setFontColor('#FFFFFF'); ajustes.setColumnWidth(1, 170); ajustes.setColumnWidth(2, 520); ajustes.setColumnWidth(3, 360); ajustes.getRange(2, 2, filas.length - 1, 1).setWrap(true); ajustes.setFrozenRows(1); } // Hoja Registro let registro = ss.getSheetByName(HOJA_REGISTRO); if (!registro) { registro = ss.insertSheet(HOJA_REGISTRO); registro.getRange(1, 1, 1, 5).setValues([['Fecha', 'ID', 'Cliente', 'Acción', 'Detalle']]) .setFontWeight('bold').setBackground('#14213D').setFontColor('#FFFFFF'); registro.setFrozenRows(1); } ss.setActiveSheet(hoja); const avisoZona = avisoZonaHoraria_(ss); SpreadsheetApp.getUi().alert( 'Listo. Revisa la hoja "Ajustes" (pon tu nombre y negocio) y después usa ' + '🤖 Automático → "Revisar presupuestos ahora" para probar.' + (avisoZona ? '\n\n⚠️ ' + avisoZona : '') ); } /** Si la hoja y el script usan zonas horarias distintas, los días pueden contarse con 1 de diferencia. */ function avisoZonaHoraria_(ss) { try { const zonaScript = Session.getScriptTimeZone(); const zonaHoja = ss.getSpreadsheetTimeZone(); if (zonaScript && zonaHoja && zonaScript !== zonaHoja) { return 'Zona horaria distinta: la hoja usa ' + zonaHoja + ' y el script ' + zonaScript + '. Ponlas iguales (Apps Script → ⚙️ Configuración del proyecto → Zona horaria).'; } } catch (e) { /* si no se puede comprobar, seguimos */ } return ''; } function aplicarColores_(hoja) { const rango = hoja.getRange('A2:K'); const reglas = [ [ESTADOS.ACEPTADO, '#D8F3DC'], [ESTADOS.RECHAZADO, '#F1F1F1'], [ESTADOS.SIN_RESPUESTA, '#FDE2E4'] ].map(function (par) { return SpreadsheetApp.newConditionalFormatRule() .whenFormulaSatisfied('=$G2="' + par[0] + '"') .setBackground(par[1]).setRanges([rango]).build(); }); hoja.setConditionalFormatRules(reglas); } /* ========================================================================= * PROCESO PRINCIPAL * ========================================================================= */ function revisarPresupuestosConAviso() { const r = revisarPresupuestos(); SpreadsheetApp.getUi().alert( 'Revisión terminada.\n\n' + 'Seguimientos preparados: ' + r.seguimientos + (r.modo === 'ENVIAR' ? ' (enviados)' : ' (borradores en Gmail)') + '\n' + 'Marcados "Sin respuesta": ' + r.cerrados + '\n' + 'Avisos: ' + (r.errores.length ? '\n- ' + r.errores.join('\n- ') : 'ninguno') ); } function revisarPresupuestos() { const ss = SpreadsheetApp.getActive(); const hoja = ss.getSheetByName(HOJA_PRESUPUESTOS); if (!hoja) throw new Error('No existe la hoja "' + HOJA_PRESUPUESTOS + '". Ejecuta "Configurar hojas".'); const ajustes = leerAjustes_(ss); const datos = hoja.getDataRange().getValues(); const hoy = new Date(); const resultado = { seguimientos: 0, cerrados: 0, errores: [], modo: ajustes.modo }; const avisoZona = avisoZonaHoraria_(ss); if (avisoZona) resultado.errores.push(avisoZona); let cupo = Infinity; if (ajustes.modo === 'ENVIAR') cupo = MailApp.getRemainingDailyQuota(); for (let i = 1; i < datos.length; i++) { const fila = datos[i]; if (!fila[COL.CLIENTE] && !fila[COL.EMAIL]) continue; // fila vacía const numFila = i + 1; const accion = calcularAccion(fila, hoy, ajustes); if (accion.tipo === 'SEGUIMIENTO') { if (cupo <= 0) { resultado.errores.push('Fila ' + numFila + ': sin cupo diario de envío de Gmail. Se intentará mañana.'); continue; } const datosTexto = datosPlantilla(fila, ajustes); const asunto = rellenarPlantilla(ajustes['asunto_' + accion.paso], datosTexto); const cuerpo = rellenarPlantilla(ajustes['cuerpo_' + accion.paso], datosTexto); const email = String(fila[COL.EMAIL]).trim(); const opciones = { name: ajustes.tu_nombre }; if (ajustes.modo === 'ENVIAR') { GmailApp.sendEmail(email, asunto, cuerpo, opciones); cupo--; } else { GmailApp.createDraft(email, asunto, cuerpo, opciones); } const fechaEnvio = parsearFecha(fila[COL.FECHA_ENVIO]); hoja.getRange(numFila, COL.SEGUIMIENTOS + 1).setValue(accion.paso); hoja.getRange(numFila, COL.ULTIMO + 1).setValue(hoy); hoja.getRange(numFila, COL.PROXIMO + 1).setValue(proximaFecha(fechaEnvio, accion.paso, ajustes) || ''); registrar_(ss, fila, (ajustes.modo === 'ENVIAR' ? 'Enviado' : 'Borrador') + ' seguimiento ' + accion.paso, asunto); resultado.seguimientos++; } else if (accion.tipo === 'CERRAR') { hoja.getRange(numFila, COL.ESTADO + 1).setValue(ESTADOS.SIN_RESPUESTA); hoja.getRange(numFila, COL.PROXIMO + 1).setValue(''); registrar_(ss, fila, 'Marcado "Sin respuesta"', accion.motivo); resultado.cerrados++; } else if (accion.tipo === 'ERROR') { resultado.errores.push('Fila ' + numFila + ': ' + accion.motivo); } else if (String(fila[COL.ESTADO]).trim() === ESTADOS.ENVIADO) { // Sin acción hoy: dejamos visible cuándo toca la próxima const f = parsearFecha(fila[COL.FECHA_ENVIO]); const n = Number(fila[COL.SEGUIMIENTOS]) || 0; if (f) hoja.getRange(numFila, COL.PROXIMO + 1).setValue(proximaFecha(f, n, ajustes) || ''); } else { hoja.getRange(numFila, COL.PROXIMO + 1).setValue(''); } } return resultado; } /* ========================================================================= * LÓGICA PURA (sin dependencias de Google: se puede probar fuera de Apps Script) * ========================================================================= */ /** * Decide qué hacer con una fila. * Devuelve { tipo: 'NINGUNA' | 'SEGUIMIENTO' | 'CERRAR' | 'ERROR', paso, motivo } */ function calcularAccion(fila, hoy, ajustes) { const estado = String(fila[COL.ESTADO] || '').trim(); if (estado !== ESTADOS.ENVIADO) return { tipo: 'NINGUNA', motivo: 'Estado: ' + (estado || 'vacío') }; const email = String(fila[COL.EMAIL] || '').trim(); if (!esEmailValido(email)) return { tipo: 'ERROR', motivo: 'email no válido ("' + email + '")' }; const fechaEnvio = parsearFecha(fila[COL.FECHA_ENVIO]); if (!fechaEnvio) return { tipo: 'ERROR', motivo: 'fecha de envío vacía o no válida' }; const dias = diasEntre(fechaEnvio, hoy); if (dias < 0) return { tipo: 'ERROR', motivo: 'la fecha de envío está en el futuro' }; const ultimo = parsearFecha(fila[COL.ULTIMO]); if (ultimo && diasEntre(ultimo, hoy) === 0) return { tipo: 'NINGUNA', motivo: 'ya se hizo un seguimiento hoy' }; const n = Math.max(0, Math.min(MAX_SEGUIMIENTOS, Number(fila[COL.SEGUIMIENTOS]) || 0)); const umbrales = umbrales_(ajustes); if (n < MAX_SEGUIMIENTOS) { if (dias >= umbrales[n]) return { tipo: 'SEGUIMIENTO', paso: n + 1, motivo: dias + ' días desde el envío' }; return { tipo: 'NINGUNA', motivo: 'faltan ' + (umbrales[n] - dias) + ' días para el seguimiento ' + (n + 1) }; } const diaCierre = umbrales[MAX_SEGUIMIENTOS - 1] + Number(ajustes.dias_cierre); if (dias >= diaCierre) return { tipo: 'CERRAR', motivo: dias + ' días sin respuesta tras ' + MAX_SEGUIMIENTOS + ' seguimientos' }; return { tipo: 'NINGUNA', motivo: 'esperando respuesta al último seguimiento' }; } /** Fecha de la próxima acción tras haber hecho `hechos` seguimientos. */ function proximaFecha(fechaEnvio, hechos, ajustes) { if (!fechaEnvio) return null; const umbrales = umbrales_(ajustes); let dias; if (hechos < MAX_SEGUIMIENTOS) dias = umbrales[hechos]; else dias = umbrales[MAX_SEGUIMIENTOS - 1] + Number(ajustes.dias_cierre); const d = new Date(fechaEnvio.getFullYear(), fechaEnvio.getMonth(), fechaEnvio.getDate()); d.setDate(d.getDate() + dias); return d; } function umbrales_(ajustes) { const u = [ Number(ajustes.dias_seguimiento_1), Number(ajustes.dias_seguimiento_2), Number(ajustes.dias_seguimiento_3) ]; // Si alguien pone valores incoherentes (p. ej. 7, 3, 14) los ordenamos. return u.map(function (x, i) { return isNaN(x) || x < 0 ? [3, 7, 14][i] : x; }) .sort(function (a, b) { return a - b; }); } function datosPlantilla(fila, ajustes) { const f = parsearFecha(fila[COL.FECHA_ENVIO]); return { cliente: String(fila[COL.CLIENTE] || '').trim(), concepto: String(fila[COL.CONCEPTO] || '').trim(), importe: formatearImporte(fila[COL.IMPORTE]), fecha_envio: f ? formatearFecha(f) : '', id: String(fila[COL.ID] || '').trim(), tu_nombre: ajustes.tu_nombre, tu_negocio: ajustes.tu_negocio }; } /** Sustituye {clave} por su valor. Las claves desconocidas se dejan tal cual. */ function rellenarPlantilla(texto, datos) { return String(texto || '').replace(/\{([a-z_]+)\}/g, function (todo, clave) { return Object.prototype.hasOwnProperty.call(datos, clave) ? String(datos[clave]) : todo; }); } function esFecha_(v) { return Object.prototype.toString.call(v) === '[object Date]' && !isNaN(v.getTime()); } /** Acepta objetos Date (lo normal en Sheets), "dd/mm/aaaa" o "aaaa-mm-dd". */ function parsearFecha(v) { if (esFecha_(v)) return v; if (v === null || v === undefined || v === '') return null; const s = String(v).trim(); let m = s.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})$/); if (m) { let anio = Number(m[3]); if (anio < 100) anio += 2000; const d = new Date(anio, Number(m[2]) - 1, Number(m[1])); return d.getDate() === Number(m[1]) && d.getMonth() === Number(m[2]) - 1 ? d : null; } m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); if (m) { const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return d.getDate() === Number(m[3]) ? d : null; } return null; } /** Días naturales entre dos fechas (ignora horas). */ function diasEntre(a, b) { const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()); const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()); return Math.round((ub - ua) / 86400000); } function formatearFecha(d) { const dd = ('0' + d.getDate()).slice(-2); const mm = ('0' + (d.getMonth() + 1)).slice(-2); return dd + '/' + mm + '/' + d.getFullYear(); } /** 3200 -> "3.200,00 €" */ function formatearImporte(v) { const n = typeof v === 'number' ? v : Number(String(v || '').replace(/[^\d,.-]/g, '').replace(/\./g, '').replace(',', '.')); if (isNaN(n) || v === '' || v === null || v === undefined) return ''; const partes = Math.abs(n).toFixed(2).split('.'); const miles = partes[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return (n < 0 ? '-' : '') + miles + ',' + partes[1] + ' €'; } function esEmailValido(email) { return /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]{2,}$/.test(String(email || '').trim()); } /** Calcula el resumen de la cartera (también lógica pura). */ function calcularResumen(filas, hoy, ajustes) { const r = { pendientes: 0, importePendiente: 0, aceptados: 0, importeAceptado: 0, proximos7: [] }; filas.forEach(function (fila) { const estado = String(fila[COL.ESTADO] || '').trim(); const importe = Number(fila[COL.IMPORTE]) || 0; if (estado === ESTADOS.ENVIADO) { r.pendientes++; r.importePendiente += importe; const f = parsearFecha(fila[COL.FECHA_ENVIO]); if (f) { const prox = proximaFecha(f, Number(fila[COL.SEGUIMIENTOS]) || 0, ajustes); const faltan = diasEntre(hoy, prox); if (faltan <= 7) r.proximos7.push({ cliente: fila[COL.CLIENTE], concepto: fila[COL.CONCEPTO], fecha: formatearFecha(prox) }); } } else if (estado === ESTADOS.ACEPTADO) { r.aceptados++; r.importeAceptado += importe; } }); return r; } /* ========================================================================= * RESUMEN SEMANAL * ========================================================================= */ function enviarResumen() { const ss = SpreadsheetApp.getActive(); const hoja = ss.getSheetByName(HOJA_PRESUPUESTOS); const ajustes = leerAjustes_(ss); const filas = hoja.getDataRange().getValues().slice(1); const r = calcularResumen(filas, new Date(), ajustes); const destino = ajustes.email_resumen || Session.getActiveUser().getEmail(); if (!destino) throw new Error('No se pudo saber tu email. Rellena "email_resumen" en Ajustes.'); let cuerpo = 'Resumen de tus presupuestos\n\n' + '• Pendientes de respuesta: ' + r.pendientes + ' (' + formatearImporte(r.importePendiente) + ')\n' + '• Aceptados en la hoja: ' + r.aceptados + ' (' + formatearImporte(r.importeAceptado) + ')\n\n'; if (r.proximos7.length) { cuerpo += 'Seguimientos previstos en los próximos 7 días:\n'; r.proximos7.forEach(function (p) { cuerpo += ' - ' + p.fecha + ' · ' + p.cliente + ' · ' + p.concepto + '\n'; }); } else { cuerpo += 'No hay seguimientos previstos esta semana.\n'; } cuerpo += '\nHoja: ' + ss.getUrl(); MailApp.sendEmail(destino, 'Resumen semanal de presupuestos', cuerpo); return destino; } function enviarResumenConAviso() { const destino = enviarResumen(); SpreadsheetApp.getUi().alert('Resumen enviado a ' + destino + '. Míralo en tu bandeja de entrada.'); } /* ========================================================================= * ACTIVADORES (automatización diaria) * ========================================================================= */ function activarAutomatico() { desactivarAutomatico(true); ScriptApp.newTrigger('revisarPresupuestos').timeBased().everyDays(1).atHour(9).create(); ScriptApp.newTrigger('enviarResumen').timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8).create(); SpreadsheetApp.getUi().alert('Activado: revisión diaria hacia las 9:00 y resumen los lunes hacia las 8:00 (zona horaria del proyecto).'); } function desactivarAutomatico(silencioso) { ScriptApp.getProjectTriggers().forEach(function (t) { const f = t.getHandlerFunction(); if (f === 'revisarPresupuestos' || f === 'enviarResumen') ScriptApp.deleteTrigger(t); }); if (silencioso !== true) SpreadsheetApp.getUi().alert('Automático desactivado.'); } /* ========================================================================= * UTILIDADES DE HOJA * ========================================================================= */ function leerAjustes_(ss) { const a = {}; Object.keys(AJUSTES_POR_DEFECTO).forEach(function (k) { a[k] = AJUSTES_POR_DEFECTO[k]; }); const hoja = ss.getSheetByName(HOJA_AJUSTES); if (hoja) { hoja.getDataRange().getValues().slice(1).forEach(function (f) { const clave = String(f[0] || '').trim(); if (clave && f[1] !== '' && f[1] !== null) a[clave] = f[1]; }); } a.modo = String(a.modo).trim().toUpperCase() === 'ENVIAR' ? 'ENVIAR' : 'BORRADOR'; return a; } function registrar_(ss, fila, accion, detalle) { const hoja = ss.getSheetByName(HOJA_REGISTRO); if (!hoja) return; hoja.appendRow([new Date(), fila[COL.ID], fila[COL.CLIENTE], accion, detalle]); }