{ "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" } ] } }