From a9f9fc76356b9e36ab53358cc469c9dc0854ae19 Mon Sep 17 00:00:00 2001 From: Isaac_Aracena Date: Sun, 2 Aug 2026 04:00:42 +0000 Subject: [PATCH] Create backup: GLM - Solicitud mensual de EEFF a contadores --- ...olicitud-mensual-de-eeff-a-contadores.json | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 glm-solicitud-mensual-de-eeff-a-contadores.json 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