diff --git a/glm-solicitud-mensual-de-eeff-a-contadores.json b/glm-solicitud-mensual-de-eeff-a-contadores.json
new file mode 100644
index 0000000..7aaead4
--- /dev/null
+++ b/glm-solicitud-mensual-de-eeff-a-contadores.json
@@ -0,0 +1,407 @@
+{
+ "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, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\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\n\n
\n \n\n \n\n \n\n \n\n ${escapeHtml(subject)}\n\n\n\n \n \n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n
\n\n \n \n \n \n \n \n | \n \n | \n \n\n \n \n \n \n | \n \n\n \n \n | \n \n ${escapeHtml(monthNameTitle)} ${year}\n \n\n \n Solicitud de Estados Financieros\n \n | \n \n\n \n \n | \n Estimado equipo de\n \n ${escapeHtml(group.firma_operativa)}\n ,\n | \n \n\n \n \n | \n Reciban un cordial saludo. Agradeceremos nos compartan los\n \n Estados Financieros (EEFF)\n \n correspondientes al mes de\n \n ${escapeHtml(monthName)} de ${year}\n \n de\n \n ${escapeHtml(group.empresa_glm)}\n .\n | \n \n\n \n \n \n \n \n | \n Período solicitado\n | \n \n\n \n | \n ${escapeHtml(monthNameTitle)} ${year}\n | \n \n \n | \n \n\n \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 | \n \n\n \n \n | \n Agradezco de antemano su colaboración.\n | \n \n\n \n \n \n Saludos cordiales,\n \n \n\n \n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n \n | \n \n\n \n \n \n \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 | \n \n \n | \n \n \n | \n
\n
\n\n\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 ",
+ "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, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\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\n\n\n \n\n \n\n \n\n \n\n ${escapeHtml(subject)}\n\n\n\n \n \n Solicitud de Estados Financieros correspondientes a\n ${escapeHtml(monthName)} de ${year}.\n
\n\n \n \n \n \n \n \n | \n \n | \n \n\n \n \n \n \n | \n \n\n \n \n | \n \n ${escapeHtml(monthNameTitle)} ${year}\n \n\n \n Solicitud de Estados Financieros\n \n | \n \n\n \n \n | \n Estimado equipo de\n \n ${escapeHtml(group.firma_operativa)}\n ,\n | \n \n\n \n \n | \n Reciban un cordial saludo. Agradeceremos nos compartan los\n \n Estados Financieros (EEFF)\n \n correspondientes al mes de\n \n ${escapeHtml(monthName)} de ${year}\n \n de\n \n ${escapeHtml(group.empresa_glm)}\n .\n | \n \n\n \n \n \n \n \n | \n Período solicitado\n | \n \n\n \n | \n ${escapeHtml(monthNameTitle)} ${year}\n | \n \n \n | \n \n\n \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 | \n \n\n \n \n | \n Agradezco de antemano su colaboración.\n | \n \n\n \n \n \n Saludos cordiales,\n \n \n\n \n ${escapeHtml(CONFIG.REMITENTE_NOMBRE)}\n \n | \n \n\n \n \n \n \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 | \n \n \n | \n \n \n | \n
\n
\n\n\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"
+ }
+ ]
+ }
+}
\ No newline at end of file