Files
glm-solicitud-mensual-de-ee…/glm-solicitud-mensual-de-eeff-a-contadores.json

407 lines
43 KiB
JSON

{
"updatedAt": "2026-08-01T16:43:21.632Z",
"createdAt": "2026-08-01T15:30:41.989Z",
"id": "S9LCeDiAgVQdL2fs",
"name": "GLM - Solicitud mensual de EEFF a contadores",
"description": null,
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {},
"id": "fc7e8055-4678-4c31-ad66-64de513bfd19",
"name": "Prueba manual segura",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
32,
384
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtDayOfMonth": 15,
"triggerAtHour": 8
}
]
}
},
"id": "4c04af4b-bfea-4516-8256-309fb1e67f61",
"name": "Cada día 15 a las 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
32,
560
],
"notesInFlow": true,
"notes": "Se ejecuta el día 15 de cada mes a las 8:00 a. m. usando la zona horaria America/Santo_Domingo definida en el workflow."
},
{
"parameters": {
"url": "https://sheets.googleapis.com/v4/spreadsheets/10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8/values/%27Para%20Isaac%20%28%20IT%29%27%21B3%3AM",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "majorDimension",
"value": "ROWS"
},
{
"name": "valueRenderOption",
"value": "FORMATTED_VALUE"
}
]
},
"options": {
"timeout": 120000
}
},
"id": "5a4feded-814b-45fd-98aa-401793ad823e",
"name": "Leer contactos vivos del Google Sheet",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
304,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
},
"notes": "Selecciona una credencial Google Sheets OAuth2 con acceso de lectura al archivo. Lee B3:M de la hoja Para Isaac ( IT) en cada ejecución."
},
{
"parameters": {
"jsCode": "'use strict';\n\nconst CONFIG = {\n // Déjalo vacío si no deseas copiar a nadie.\n // Para varios correos, sepáralos por coma.\n CC_INTERNO: '',\n\n // La firma visible del correo mostrará únicamente este nombre.\n REMITENTE_NOMBRE: 'Ada Rodríguez',\n\n LOGO_URL:\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n\n // El archivo se lee en vivo en cada ejecución.\n SPREADSHEET_ID: '10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8',\n HOJA: 'Para Isaac ( IT)',\n};\n\nconst response = $input.first()?.json || {};\nconst values = Array.isArray(response.values) ? response.values : [];\n\nif (values.length < 2) {\n throw new Error(\n `La hoja \"${CONFIG.HOJA}\" no contiene encabezados y filas de datos en el rango B3:M.`\n );\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\r/g, ' ')\n .replace(/\\n+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, ' ')\n .trim();\n}\n\nfunction escapeHtml(value) {\n return clean(value)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\nfunction parseEmails(value) {\n const matches = clean(value)\n .split(/[;,\\s]+/)\n .map((email) => email.trim().toLowerCase())\n .filter(Boolean)\n .filter((email) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email));\n\n return [...new Set(matches)];\n}\n\nconst EXPECTED = [\n {\n key: 'pais',\n aliases: ['pais'],\n },\n {\n key: 'contacto',\n aliases: ['nombre del contacto'],\n },\n {\n key: 'cargo',\n aliases: ['cargo'],\n },\n {\n key: 'correo',\n aliases: ['correo electronico'],\n },\n {\n key: 'telefono',\n aliases: ['telefono'],\n },\n {\n key: 'firma_operativa',\n aliases: [\n 'nombre de la firma contable o auditoria',\n 'nombre de la firma contable auditoria',\n ],\n },\n {\n key: 'empresa_glm',\n aliases: ['nombre de glm en cada pais'],\n },\n {\n key: 'pagina_web',\n aliases: ['pagina web'],\n },\n {\n key: 'contabilidad',\n aliases: ['contabilidad'],\n },\n {\n key: 'impuestos',\n aliases: ['impuestos'],\n },\n {\n key: 'auditoria',\n aliases: ['auditoria'],\n },\n {\n key: 'firma_red',\n aliases: ['nombre de la firma contable'],\n },\n];\n\nconst headers = (values[0] || []).map(clean);\nconst normalizedHeaders = headers.map(normalize);\nconst indexByKey = {};\n\nfor (const expected of EXPECTED) {\n const accepted = expected.aliases.map(normalize);\n\n const index = normalizedHeaders.findIndex((header) =>\n accepted.includes(header)\n );\n\n if (index < 0) {\n throw new Error(\n `Falta la columna requerida \"${expected.aliases[0]}\" en la hoja \"${CONFIG.HOJA}\". ` +\n `Encabezados encontrados: ${headers.join(' | ')}`\n );\n }\n\n indexByKey[expected.key] = index;\n}\n\nconst rows = values.slice(1);\nconst validRows = [];\nconst omittedRows = [];\n\nfor (let index = 0; index < rows.length; index++) {\n // Los encabezados están en la fila 3 y los datos comienzan en la fila 4.\n const sourceRow = index + 4;\n const row = Array.isArray(rows[index]) ? rows[index] : [];\n\n const record = {};\n\n for (const expected of EXPECTED) {\n record[expected.key] = clean(row[indexByKey[expected.key]]);\n }\n\n const missing = EXPECTED\n .filter((expected) => !record[expected.key])\n .map((expected) => expected.aliases[0]);\n\n if (missing.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: `Columnas incompletas: ${missing.join(', ')}`,\n });\n\n continue;\n }\n\n const emails = parseEmails(record.correo);\n\n if (!emails.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: 'Correo electrónico inválido',\n });\n\n continue;\n }\n\n validRows.push({\n ...record,\n emails,\n sourceRow,\n });\n}\n\nif (!validRows.length) {\n throw new Error(\n `No hay filas elegibles en \"${CONFIG.HOJA}\". ` +\n `Solo se procesan filas con las 12 columnas B:M completas y un correo válido.`\n );\n}\n\n/*\n * Agrupación:\n * país + empresa GLM + firma operativa.\n *\n * Los contactos del mismo equipo reciben un solo correo.\n * Esto evita duplicados y no mezcla firmas distintas.\n */\nconst groups = new Map();\n\nfor (const row of validRows) {\n const key = [\n normalize(row.pais),\n normalize(row.empresa_glm),\n normalize(row.firma_operativa),\n ].join('||');\n\n if (!groups.has(key)) {\n groups.set(key, {\n pais: row.pais,\n empresa_glm: row.empresa_glm,\n firma_operativa: row.firma_operativa,\n firma_red: row.firma_red,\n pagina_web: row.pagina_web,\n contactos: [],\n emails: new Set(),\n sourceRows: [],\n });\n }\n\n const group = groups.get(key);\n\n group.contactos.push({\n nombre: row.contacto,\n cargo: row.cargo,\n });\n\n row.emails.forEach((email) => group.emails.add(email));\n group.sourceRows.push(row.sourceRow);\n}\n\n/*\n * Determinación automática del período:\n * siempre solicita el mes inmediatamente anterior.\n */\nconst now = new Date();\n\nconst previousMonth = new Date(\n Date.UTC(\n now.getUTCFullYear(),\n now.getUTCMonth() - 1,\n 1\n )\n);\n\nconst MONTHS = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre',\n];\n\nconst monthName = MONTHS[previousMonth.getUTCMonth()];\n\nconst monthNameTitle =\n monthName.charAt(0).toUpperCase() +\n monthName.slice(1);\n\nconst year = previousMonth.getUTCFullYear();\n\nconst output = [];\n\nfor (const group of groups.values()) {\n const realRecipients = [...group.emails].sort();\n const recipientsLabel = realRecipients.join(', ');\n\n // Producción: siempre se envía a los destinatarios reales del Google Sheet.\n const to = recipientsLabel;\n const cc = CONFIG.CC_INTERNO;\n\n const subject =\n `GLM | Solicitud EEFF ${monthNameTitle} ${year} | ${group.pais}`;\n\n const html = `\n<!doctype html>\n<html lang=\"es\">\n<head>\n <meta charset=\"utf-8\">\n\n <meta\n name=\"viewport\"\n content=\"width=device-width, initial-scale=1\"\n >\n\n <meta\n name=\"color-scheme\"\n content=\"light\"\n >\n\n <meta\n name=\"supported-color-schemes\"\n content=\"light\"\n >\n\n <title>${escapeHtml(subject)}</title>\n</head>\n\n<body\n style=\"\n margin:0;\n padding:0;\n background-color:#F5F5F5;\n font-family:Arial, sans-serif;\n color:#4A4A4A;\n \"\n>\n <!-- Preheader oculto para la vista previa del correo -->\n <div\n style=\"\n display:none;\n max-height:0;\n overflow:hidden;\n opacity:0;\n color:transparent;\n line-height:1px;\n font-size:1px;\n \"\n >\n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n </div>\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n bgcolor=\"#F5F5F5\"\n style=\"\n width:100%;\n background-color:#F5F5F5;\n \"\n >\n <tr>\n <td\n align=\"center\"\n style=\"padding:32px 12px;\"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n bgcolor=\"#FFFFFF\"\n style=\"\n width:100%;\n max-width:660px;\n background-color:#FFFFFF;\n border:1px solid #DDE3E6;\n border-collapse:separate;\n border-spacing:0;\n \"\n >\n <!-- Acento superior GLM -->\n <tr>\n <td\n height=\"4\"\n bgcolor=\"#6CC24A\"\n style=\"\n height:4px;\n line-height:4px;\n font-size:0;\n background-color:#6CC24A;\n \"\n >\n &nbsp;\n </td>\n </tr>\n\n <!-- Logo -->\n <tr>\n <td\n style=\"\n padding:28px 36px 24px 36px;\n border-bottom:1px solid #E5E9EB;\n \"\n >\n <img\n src=\"${escapeHtml(CONFIG.LOGO_URL)}\"\n alt=\"GomezLee Marketing\"\n width=\"240\"\n style=\"\n display:block;\n width:240px;\n max-width:100%;\n height:auto;\n border:0;\n outline:none;\n text-decoration:none;\n \"\n >\n </td>\n </tr>\n\n <!-- Encabezado -->\n <tr>\n <td\n style=\"\n padding:34px 36px 0 36px;\n \"\n >\n <div\n style=\"\n margin:0 0 8px 0;\n font-size:12px;\n line-height:1.4;\n letter-spacing:0.8px;\n text-transform:uppercase;\n color:#6B8FA3;\n font-weight:700;\n \"\n >\n ${escapeHtml(monthNameTitle)} ${year}\n </div>\n\n <h1\n style=\"\n margin:0;\n font-size:28px;\n line-height:1.25;\n color:#4F758B;\n font-weight:700;\n letter-spacing:-0.3px;\n \"\n >\n Solicitud de Estados Financieros\n </h1>\n </td>\n </tr>\n\n <!-- Saludo -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Estimado equipo de\n <strong style=\"color:#4A4A4A;\">\n ${escapeHtml(group.firma_operativa)}\n </strong>,\n </td>\n </tr>\n\n <!-- Solicitud principal -->\n <tr>\n <td\n style=\"\n padding:14px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Reciban un cordial saludo. Agradeceremos nos compartan los\n <strong style=\"color:#4F758B;\">\n Estados Financieros (EEFF)\n </strong>\n correspondientes al mes de\n <strong>\n ${escapeHtml(monthName)} de ${year}\n </strong>\n de\n <strong>\n ${escapeHtml(group.empresa_glm)}\n </strong>.\n </td>\n </tr>\n\n <!-- Período solicitado -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n \"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"\n width:100%;\n border-collapse:collapse;\n border-left:4px solid #6CC24A;\n background-color:#F8FAFB;\n \"\n >\n <tr>\n <td\n style=\"\n padding:16px 18px 6px 18px;\n font-size:11px;\n line-height:1.4;\n letter-spacing:0.7px;\n text-transform:uppercase;\n color:#6B8FA3;\n font-weight:700;\n \"\n >\n Período solicitado\n </td>\n </tr>\n\n <tr>\n <td\n style=\"\n padding:0 18px 16px 18px;\n font-size:17px;\n line-height:1.45;\n color:#4F758B;\n font-weight:700;\n \"\n >\n ${escapeHtml(monthNameTitle)} ${year}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Asistencia -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Si necesitan alguna aclaración o se presenta algún inconveniente\n con el envío, por favor háganmelo saber y con gusto les apoyaré.\n </td>\n </tr>\n\n <!-- Agradecimiento -->\n <tr>\n <td\n style=\"\n padding:16px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Agradezco de antemano su colaboración.\n </td>\n </tr>\n\n <!-- Firma -->\n <tr>\n <td\n style=\"\n padding:26px 36px 0 36px;\n font-size:15px;\n line-height:1.65;\n color:#4A4A4A;\n \"\n >\n Saludos cordiales,\n <br>\n <br>\n\n <strong\n style=\"\n font-size:16px;\n color:#4F758B;\n \"\n >\n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n </strong>\n </td>\n </tr>\n\n <!-- Nota final -->\n <tr>\n <td\n style=\"\n padding:26px 36px 34px 36px;\n \"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"\n width:100%;\n border-collapse:collapse;\n border-top:1px solid #6CC24A;\n \"\n >\n <tr>\n <td\n style=\"\n padding:18px 0 0 0;\n font-size:13px;\n line-height:1.65;\n color:#4F758B;\n font-weight:700;\n \"\n >\n Si ya han remitido los Estados Financieros correspondientes\n a este período, por favor hagan caso omiso a este correo.\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>\n `.trim();\n\n output.push({\n json: {\n to,\n cc,\n subject,\n html,\n\n pais: group.pais,\n empresa_glm: group.empresa_glm,\n firma_operativa: group.firma_operativa,\n\n // Datos disponibles únicamente dentro de n8n para auditoría.\n destinatarios: recipientsLabel,\n filas_origen: group.sourceRows.join(', '),\n entorno: 'produccion',\n\n periodo_solicitado: `${monthNameTitle} ${year}`,\n\n total_filas_validas: validRows.length,\n total_filas_omitidas: omittedRows.length,\n detalle_filas_omitidas: omittedRows,\n },\n });\n}\n\nreturn output;"
},
"id": "308dab91-4b68-4578-86ed-2e6a61071e2a",
"name": "Validar filas y construir correos GLM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
608,
464
],
"notesInFlow": true,
"notes": "Solo procesa filas con las 12 columnas B:M completas y correo válido. Agrupa contactos por país + empresa GLM + firma. MODO_PRUEBA está activo por defecto."
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"appendAttribution": false,
"ccList": "={{ $json.cc }}",
"senderName": "Ada Rodríguez | GomezLee Marketing"
}
},
"id": "e58a1aec-7512-4d63-969a-d9d4e9893fad",
"name": "Enviar desde Gmail de Ada",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
928,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"webhookId": "673e3ad2-e80d-4d6a-a9c3-0d16e2891d9f",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"notes": "En la llamada con Ada, selecciona aquí la credencial Gmail OAuth2 conectada como asrodriguez@gomezleemarketing.com."
}
],
"connections": {
"Prueba manual segura": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Cada día 15 a las 8 AM": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Leer contactos vivos del Google Sheet": {
"main": [
[
{
"node": "Validar filas y construir correos GLM",
"type": "main",
"index": 0
}
]
]
},
"Validar filas y construir correos GLM": {
"main": [
[
{
"node": "Enviar desde Gmail de Ada",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"staticData": {
"node:Cada día 15 a las 8 AM": {
"recurrenceRules": []
}
},
"meta": {
"templateCredsSetupCompleted": true
},
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"activeVersionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"versionCounter": 9,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-08-01T15:30:41.997Z",
"createdAt": "2026-08-01T15:30:41.997Z",
"role": "workflow:owner",
"workflowId": "S9LCeDiAgVQdL2fs",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-08-01T16:43:49.000Z",
"createdAt": "2026-08-01T16:43:21.634Z",
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"workflowId": "S9LCeDiAgVQdL2fs",
"nodes": [
{
"parameters": {},
"id": "fc7e8055-4678-4c31-ad66-64de513bfd19",
"name": "Prueba manual segura",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
32,
384
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "months",
"triggerAtDayOfMonth": 15,
"triggerAtHour": 8
}
]
}
},
"id": "4c04af4b-bfea-4516-8256-309fb1e67f61",
"name": "Cada día 15 a las 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
32,
560
],
"notesInFlow": true,
"notes": "Se ejecuta el día 15 de cada mes a las 8:00 a. m. usando la zona horaria America/Santo_Domingo definida en el workflow."
},
{
"parameters": {
"url": "https://sheets.googleapis.com/v4/spreadsheets/10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8/values/%27Para%20Isaac%20%28%20IT%29%27%21B3%3AM",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleSheetsOAuth2Api",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "majorDimension",
"value": "ROWS"
},
{
"name": "valueRenderOption",
"value": "FORMATTED_VALUE"
}
]
},
"options": {
"timeout": 120000
}
},
"id": "5a4feded-814b-45fd-98aa-401793ad823e",
"name": "Leer contactos vivos del Google Sheet",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
304,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"googleSheetsOAuth2Api": {
"id": "K0hDZh3a85MpOHCs",
"name": "Google Sheets account 2"
}
},
"notes": "Selecciona una credencial Google Sheets OAuth2 con acceso de lectura al archivo. Lee B3:M de la hoja Para Isaac ( IT) en cada ejecución."
},
{
"parameters": {
"jsCode": "'use strict';\n\nconst CONFIG = {\n // Déjalo vacío si no deseas copiar a nadie.\n // Para varios correos, sepáralos por coma.\n CC_INTERNO: '',\n\n // La firma visible del correo mostrará únicamente este nombre.\n REMITENTE_NOMBRE: 'Ada Rodríguez',\n\n LOGO_URL:\n 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png',\n\n // El archivo se lee en vivo en cada ejecución.\n SPREADSHEET_ID: '10GN-XIna4YRdCt81EAdQCoq6adstiCF-HuCTDs_wBt8',\n HOJA: 'Para Isaac ( IT)',\n};\n\nconst response = $input.first()?.json || {};\nconst values = Array.isArray(response.values) ? response.values : [];\n\nif (values.length < 2) {\n throw new Error(\n `La hoja \"${CONFIG.HOJA}\" no contiene encabezados y filas de datos en el rango B3:M.`\n );\n}\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\r/g, ' ')\n .replace(/\\n+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, ' ')\n .trim();\n}\n\nfunction escapeHtml(value) {\n return clean(value)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n}\n\nfunction parseEmails(value) {\n const matches = clean(value)\n .split(/[;,\\s]+/)\n .map((email) => email.trim().toLowerCase())\n .filter(Boolean)\n .filter((email) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email));\n\n return [...new Set(matches)];\n}\n\nconst EXPECTED = [\n {\n key: 'pais',\n aliases: ['pais'],\n },\n {\n key: 'contacto',\n aliases: ['nombre del contacto'],\n },\n {\n key: 'cargo',\n aliases: ['cargo'],\n },\n {\n key: 'correo',\n aliases: ['correo electronico'],\n },\n {\n key: 'telefono',\n aliases: ['telefono'],\n },\n {\n key: 'firma_operativa',\n aliases: [\n 'nombre de la firma contable o auditoria',\n 'nombre de la firma contable auditoria',\n ],\n },\n {\n key: 'empresa_glm',\n aliases: ['nombre de glm en cada pais'],\n },\n {\n key: 'pagina_web',\n aliases: ['pagina web'],\n },\n {\n key: 'contabilidad',\n aliases: ['contabilidad'],\n },\n {\n key: 'impuestos',\n aliases: ['impuestos'],\n },\n {\n key: 'auditoria',\n aliases: ['auditoria'],\n },\n {\n key: 'firma_red',\n aliases: ['nombre de la firma contable'],\n },\n];\n\nconst headers = (values[0] || []).map(clean);\nconst normalizedHeaders = headers.map(normalize);\nconst indexByKey = {};\n\nfor (const expected of EXPECTED) {\n const accepted = expected.aliases.map(normalize);\n\n const index = normalizedHeaders.findIndex((header) =>\n accepted.includes(header)\n );\n\n if (index < 0) {\n throw new Error(\n `Falta la columna requerida \"${expected.aliases[0]}\" en la hoja \"${CONFIG.HOJA}\". ` +\n `Encabezados encontrados: ${headers.join(' | ')}`\n );\n }\n\n indexByKey[expected.key] = index;\n}\n\nconst rows = values.slice(1);\nconst validRows = [];\nconst omittedRows = [];\n\nfor (let index = 0; index < rows.length; index++) {\n // Los encabezados están en la fila 3 y los datos comienzan en la fila 4.\n const sourceRow = index + 4;\n const row = Array.isArray(rows[index]) ? rows[index] : [];\n\n const record = {};\n\n for (const expected of EXPECTED) {\n record[expected.key] = clean(row[indexByKey[expected.key]]);\n }\n\n const missing = EXPECTED\n .filter((expected) => !record[expected.key])\n .map((expected) => expected.aliases[0]);\n\n if (missing.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: `Columnas incompletas: ${missing.join(', ')}`,\n });\n\n continue;\n }\n\n const emails = parseEmails(record.correo);\n\n if (!emails.length) {\n omittedRows.push({\n fila: sourceRow,\n motivo: 'Correo electrónico inválido',\n });\n\n continue;\n }\n\n validRows.push({\n ...record,\n emails,\n sourceRow,\n });\n}\n\nif (!validRows.length) {\n throw new Error(\n `No hay filas elegibles en \"${CONFIG.HOJA}\". ` +\n `Solo se procesan filas con las 12 columnas B:M completas y un correo válido.`\n );\n}\n\n/*\n * Agrupación:\n * país + empresa GLM + firma operativa.\n *\n * Los contactos del mismo equipo reciben un solo correo.\n * Esto evita duplicados y no mezcla firmas distintas.\n */\nconst groups = new Map();\n\nfor (const row of validRows) {\n const key = [\n normalize(row.pais),\n normalize(row.empresa_glm),\n normalize(row.firma_operativa),\n ].join('||');\n\n if (!groups.has(key)) {\n groups.set(key, {\n pais: row.pais,\n empresa_glm: row.empresa_glm,\n firma_operativa: row.firma_operativa,\n firma_red: row.firma_red,\n pagina_web: row.pagina_web,\n contactos: [],\n emails: new Set(),\n sourceRows: [],\n });\n }\n\n const group = groups.get(key);\n\n group.contactos.push({\n nombre: row.contacto,\n cargo: row.cargo,\n });\n\n row.emails.forEach((email) => group.emails.add(email));\n group.sourceRows.push(row.sourceRow);\n}\n\n/*\n * Determinación automática del período:\n * siempre solicita el mes inmediatamente anterior.\n */\nconst now = new Date();\n\nconst previousMonth = new Date(\n Date.UTC(\n now.getUTCFullYear(),\n now.getUTCMonth() - 1,\n 1\n )\n);\n\nconst MONTHS = [\n 'enero',\n 'febrero',\n 'marzo',\n 'abril',\n 'mayo',\n 'junio',\n 'julio',\n 'agosto',\n 'septiembre',\n 'octubre',\n 'noviembre',\n 'diciembre',\n];\n\nconst monthName = MONTHS[previousMonth.getUTCMonth()];\n\nconst monthNameTitle =\n monthName.charAt(0).toUpperCase() +\n monthName.slice(1);\n\nconst year = previousMonth.getUTCFullYear();\n\nconst output = [];\n\nfor (const group of groups.values()) {\n const realRecipients = [...group.emails].sort();\n const recipientsLabel = realRecipients.join(', ');\n\n // Producción: siempre se envía a los destinatarios reales del Google Sheet.\n const to = recipientsLabel;\n const cc = CONFIG.CC_INTERNO;\n\n const subject =\n `GLM | Solicitud EEFF ${monthNameTitle} ${year} | ${group.pais}`;\n\n const html = `\n<!doctype html>\n<html lang=\"es\">\n<head>\n <meta charset=\"utf-8\">\n\n <meta\n name=\"viewport\"\n content=\"width=device-width, initial-scale=1\"\n >\n\n <meta\n name=\"color-scheme\"\n content=\"light\"\n >\n\n <meta\n name=\"supported-color-schemes\"\n content=\"light\"\n >\n\n <title>${escapeHtml(subject)}</title>\n</head>\n\n<body\n style=\"\n margin:0;\n padding:0;\n background-color:#F5F5F5;\n font-family:Arial, sans-serif;\n color:#4A4A4A;\n \"\n>\n <!-- Preheader oculto para la vista previa del correo -->\n <div\n style=\"\n display:none;\n max-height:0;\n overflow:hidden;\n opacity:0;\n color:transparent;\n line-height:1px;\n font-size:1px;\n \"\n >\n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n </div>\n\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n bgcolor=\"#F5F5F5\"\n style=\"\n width:100%;\n background-color:#F5F5F5;\n \"\n >\n <tr>\n <td\n align=\"center\"\n style=\"padding:32px 12px;\"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n bgcolor=\"#FFFFFF\"\n style=\"\n width:100%;\n max-width:660px;\n background-color:#FFFFFF;\n border:1px solid #DDE3E6;\n border-collapse:separate;\n border-spacing:0;\n \"\n >\n <!-- Acento superior GLM -->\n <tr>\n <td\n height=\"4\"\n bgcolor=\"#6CC24A\"\n style=\"\n height:4px;\n line-height:4px;\n font-size:0;\n background-color:#6CC24A;\n \"\n >\n &nbsp;\n </td>\n </tr>\n\n <!-- Logo -->\n <tr>\n <td\n style=\"\n padding:28px 36px 24px 36px;\n border-bottom:1px solid #E5E9EB;\n \"\n >\n <img\n src=\"${escapeHtml(CONFIG.LOGO_URL)}\"\n alt=\"GomezLee Marketing\"\n width=\"240\"\n style=\"\n display:block;\n width:240px;\n max-width:100%;\n height:auto;\n border:0;\n outline:none;\n text-decoration:none;\n \"\n >\n </td>\n </tr>\n\n <!-- Encabezado -->\n <tr>\n <td\n style=\"\n padding:34px 36px 0 36px;\n \"\n >\n <div\n style=\"\n margin:0 0 8px 0;\n font-size:12px;\n line-height:1.4;\n letter-spacing:0.8px;\n text-transform:uppercase;\n color:#6B8FA3;\n font-weight:700;\n \"\n >\n ${escapeHtml(monthNameTitle)} ${year}\n </div>\n\n <h1\n style=\"\n margin:0;\n font-size:28px;\n line-height:1.25;\n color:#4F758B;\n font-weight:700;\n letter-spacing:-0.3px;\n \"\n >\n Solicitud de Estados Financieros\n </h1>\n </td>\n </tr>\n\n <!-- Saludo -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Estimado equipo de\n <strong style=\"color:#4A4A4A;\">\n ${escapeHtml(group.firma_operativa)}\n </strong>,\n </td>\n </tr>\n\n <!-- Solicitud principal -->\n <tr>\n <td\n style=\"\n padding:14px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Reciban un cordial saludo. Agradeceremos nos compartan los\n <strong style=\"color:#4F758B;\">\n Estados Financieros (EEFF)\n </strong>\n correspondientes al mes de\n <strong>\n ${escapeHtml(monthName)} de ${year}\n </strong>\n de\n <strong>\n ${escapeHtml(group.empresa_glm)}\n </strong>.\n </td>\n </tr>\n\n <!-- Período solicitado -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n \"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"\n width:100%;\n border-collapse:collapse;\n border-left:4px solid #6CC24A;\n background-color:#F8FAFB;\n \"\n >\n <tr>\n <td\n style=\"\n padding:16px 18px 6px 18px;\n font-size:11px;\n line-height:1.4;\n letter-spacing:0.7px;\n text-transform:uppercase;\n color:#6B8FA3;\n font-weight:700;\n \"\n >\n Período solicitado\n </td>\n </tr>\n\n <tr>\n <td\n style=\"\n padding:0 18px 16px 18px;\n font-size:17px;\n line-height:1.45;\n color:#4F758B;\n font-weight:700;\n \"\n >\n ${escapeHtml(monthNameTitle)} ${year}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Asistencia -->\n <tr>\n <td\n style=\"\n padding:24px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Si necesitan alguna aclaración o se presenta algún inconveniente\n con el envío, por favor háganmelo saber y con gusto les apoyaré.\n </td>\n </tr>\n\n <!-- Agradecimiento -->\n <tr>\n <td\n style=\"\n padding:16px 36px 0 36px;\n font-size:15px;\n line-height:1.75;\n color:#4A4A4A;\n \"\n >\n Agradezco de antemano su colaboración.\n </td>\n </tr>\n\n <!-- Firma -->\n <tr>\n <td\n style=\"\n padding:26px 36px 0 36px;\n font-size:15px;\n line-height:1.65;\n color:#4A4A4A;\n \"\n >\n Saludos cordiales,\n <br>\n <br>\n\n <strong\n style=\"\n font-size:16px;\n color:#4F758B;\n \"\n >\n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n </strong>\n </td>\n </tr>\n\n <!-- Nota final -->\n <tr>\n <td\n style=\"\n padding:26px 36px 34px 36px;\n \"\n >\n <table\n role=\"presentation\"\n width=\"100%\"\n cellspacing=\"0\"\n cellpadding=\"0\"\n border=\"0\"\n style=\"\n width:100%;\n border-collapse:collapse;\n border-top:1px solid #6CC24A;\n \"\n >\n <tr>\n <td\n style=\"\n padding:18px 0 0 0;\n font-size:13px;\n line-height:1.65;\n color:#4F758B;\n font-weight:700;\n \"\n >\n Si ya han remitido los Estados Financieros correspondientes\n a este período, por favor hagan caso omiso a este correo.\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>\n `.trim();\n\n output.push({\n json: {\n to,\n cc,\n subject,\n html,\n\n pais: group.pais,\n empresa_glm: group.empresa_glm,\n firma_operativa: group.firma_operativa,\n\n // Datos disponibles únicamente dentro de n8n para auditoría.\n destinatarios: recipientsLabel,\n filas_origen: group.sourceRows.join(', '),\n entorno: 'produccion',\n\n periodo_solicitado: `${monthNameTitle} ${year}`,\n\n total_filas_validas: validRows.length,\n total_filas_omitidas: omittedRows.length,\n detalle_filas_omitidas: omittedRows,\n },\n });\n}\n\nreturn output;"
},
"id": "308dab91-4b68-4578-86ed-2e6a61071e2a",
"name": "Validar filas y construir correos GLM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
608,
464
],
"notesInFlow": true,
"notes": "Solo procesa filas con las 12 columnas B:M completas y correo válido. Agrupa contactos por país + empresa GLM + firma. MODO_PRUEBA está activo por defecto."
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"appendAttribution": false,
"ccList": "={{ $json.cc }}",
"senderName": "Ada Rodríguez | GomezLee Marketing"
}
},
"id": "e58a1aec-7512-4d63-969a-d9d4e9893fad",
"name": "Enviar desde Gmail de Ada",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.2,
"position": [
928,
464
],
"notesInFlow": true,
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"webhookId": "673e3ad2-e80d-4d6a-a9c3-0d16e2891d9f",
"credentials": {
"gmailOAuth2": {
"id": "UDcO1FLJqA453V2D",
"name": "Gmail account 3"
}
},
"notes": "En la llamada con Ada, selecciona aquí la credencial Gmail OAuth2 conectada como asrodriguez@gomezleemarketing.com."
}
],
"connections": {
"Prueba manual segura": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Cada día 15 a las 8 AM": {
"main": [
[
{
"node": "Leer contactos vivos del Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Leer contactos vivos del Google Sheet": {
"main": [
[
{
"node": "Validar filas y construir correos GLM",
"type": "main",
"index": 0
}
]
]
},
"Validar filas y construir correos GLM": {
"main": [
[
{
"node": "Enviar desde Gmail de Ada",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 2aca8a9b",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-08-01T16:43:49.920Z",
"id": 3844,
"workflowId": "S9LCeDiAgVQdL2fs",
"versionId": "2aca8a9b-301c-4809-80bb-bfdce75089a5",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}