diff --git a/ENVIO FINAL - Detalles de finiquito a colaboradores.json b/ENVIO FINAL - Detalles de finiquito a colaboradores.json
new file mode 100644
index 0000000..c7b912d
--- /dev/null
+++ b/ENVIO FINAL - Detalles de finiquito a colaboradores.json
@@ -0,0 +1,398 @@
+{
+ "nodes": [
+ {
+ "parameters": {},
+ "id": "e909153a-a0ed-454b-8795-60483cd6c1cd",
+ "name": "Manual Trigger",
+ "type": "n8n-nodes-base.manualTrigger",
+ "typeVersion": 1,
+ "position": [
+ 368,
+ 2000
+ ]
+ },
+ {
+ "parameters": {
+ "url": "https://sheets.googleapis.com/v4/spreadsheets/1Y9XhRjSSxtymV7Nm0gYbC5lLuGD2zPTc7IVb8PxoxjY/values/CONTROL_ENVIOS!A:L",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "googleSheetsOAuth2Api",
+ "options": {}
+ },
+ "id": "174cb623-eaf2-4839-a519-80e6cfbac019",
+ "name": "Leer CONTROL_ENVIOS",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 624,
+ 2000
+ ],
+ "retryOnFail": true,
+ "maxTries": 3,
+ "waitBetweenTries": 5000,
+ "credentials": {
+ "googleSheetsOAuth2Api": {
+ "id": "K0hDZh3a85MpOHCs",
+ "name": "Google Sheets account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const response = $input.first().json;\nconst values = response.values || [];\n\nif (!Array.isArray(values) || values.length < 2) {\n throw new Error('No se recibieron filas desde CONTROL_ENVIOS. Revisa el ID del Google Sheet y el nombre de la hoja.');\n}\n\nfunction normalizeKey(value) {\n return String(value || '')\n .trim()\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction normalizeText(value) {\n return String(value || '')\n .trim()\n .toUpperCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '');\n}\n\nfunction colToLetter(colNumber) {\n let temp = '';\n let letter = '';\n\n while (colNumber > 0) {\n temp = (colNumber - 1) % 26;\n letter = String.fromCharCode(temp + 65) + letter;\n colNumber = (colNumber - temp - 1) / 26;\n }\n\n return letter;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const direct = row[key];\n const normalized = row[normalizeKey(key)];\n\n if (direct !== undefined && direct !== null && String(direct).trim() !== '') {\n return String(direct).trim();\n }\n\n if (normalized !== undefined && normalized !== null && String(normalized).trim() !== '') {\n return String(normalized).trim();\n }\n }\n\n return '';\n}\n\nconst headers = values[0].map(header => String(header || '').trim());\nconst headerMap = {};\n\nheaders.forEach((header, index) => {\n headerMap[normalizeKey(header)] = {\n name: header,\n index,\n letter: colToLetter(index + 1)\n };\n});\n\nfunction requireHeader(nameOptions, label) {\n for (const option of nameOptions) {\n const normalized = normalizeKey(option);\n if (headerMap[normalized]) {\n return headerMap[normalized];\n }\n }\n\n throw new Error(`No encontré la columna requerida: ${label}.`);\n}\n\nconst colNo = requireHeader(['NO.', 'NO', 'No'], 'NO.');\nconst colHoja = requireHeader(['Hoja'], 'Hoja');\nconst colNombre = requireHeader(['Nombre'], 'Nombre');\nconst colEmail = requireHeader(['Email personal', 'Email Personal', 'Correo personal'], 'Email personal');\nconst colPdfFileId = requireHeader(['PDF File ID'], 'PDF File ID');\nconst colPdfUrl = requireHeader(['PDF URL'], 'PDF URL');\nconst colEstado = requireHeader(['Estado envío', 'Estado Envio'], 'Estado envío');\nconst colObservacion = requireHeader(['Observación', 'Observacion'], 'Observación');\n\nconst rows = values.slice(1).map((row, index) => {\n const obj = {\n __rowNumber: index + 2\n };\n\n headers.forEach((header, colIndex) => {\n const value = row[colIndex] !== undefined ? String(row[colIndex]).trim() : '';\n obj[header] = value;\n obj[normalizeKey(header)] = value;\n });\n\n return obj;\n});\n\nconst emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/i;\n\nconst candidatos = rows\n .map(row => {\n const rowNumber = row.__rowNumber;\n const noCaso = getValue(row, ['NO.', 'NO', 'No']);\n const hoja = getValue(row, ['Hoja']);\n const nombre = getValue(row, ['Nombre']).replace(/\\s+/g, ' ').trim();\n const emailPersonal = getValue(row, ['Email personal', 'Email Personal', 'Correo personal']).toLowerCase();\n const pdfFileId = getValue(row, ['PDF File ID']);\n let pdfUrl = getValue(row, ['PDF URL']);\n const estadoEnvio = getValue(row, ['Estado envío', 'Estado Envio']);\n const observacionActual = getValue(row, ['Observación', 'Observacion']);\n\n if (!pdfUrl && pdfFileId) {\n pdfUrl = `https://drive.google.com/file/d/${pdfFileId}/view?usp=drivesdk`;\n }\n\n return {\n rowNumber,\n noCaso,\n hoja,\n nombre,\n emailPersonal,\n pdfFileId,\n pdfUrl,\n estadoEnvio,\n observacionActual,\n estadoRange: `CONTROL_ENVIOS!${colEstado.letter}${rowNumber}`,\n observacionRange: `CONTROL_ENVIOS!${colObservacion.letter}${rowNumber}`\n };\n })\n .filter(row => normalizeText(row.estadoEnvio) === 'LISTO_PARA_ENVIAR');\n\nif (candidatos.length === 0) {\n throw new Error('No hay filas con Estado envío = LISTO_PARA_ENVIAR. No se enviará ningún correo.');\n}\n\nconst errores = [];\nconst seenEmails = new Map();\n\nfor (const row of candidatos) {\n if (!row.nombre) {\n errores.push(`Fila ${row.rowNumber}: falta Nombre.`);\n }\n\n if (!row.emailPersonal) {\n errores.push(`Fila ${row.rowNumber} / ${row.nombre}: falta Email personal.`);\n } else if (!emailRegex.test(row.emailPersonal)) {\n errores.push(`Fila ${row.rowNumber} / ${row.nombre}: email inválido \"${row.emailPersonal}\".`);\n } else if (row.emailPersonal.includes('@gamil.')) {\n errores.push(`Fila ${row.rowNumber} / ${row.nombre}: email sospechoso con dominio gamil.com \"${row.emailPersonal}\".`);\n }\n\n if (!row.pdfFileId) {\n errores.push(`Fila ${row.rowNumber} / ${row.nombre}: falta PDF File ID.`);\n }\n\n if (!row.pdfUrl) {\n errores.push(`Fila ${row.rowNumber} / ${row.nombre}: falta PDF URL y no se pudo construir desde PDF File ID.`);\n }\n\n const key = row.emailPersonal.toLowerCase();\n if (key) {\n if (seenEmails.has(key)) {\n errores.push(`Email duplicado \"${row.emailPersonal}\" en filas ${seenEmails.get(key)} y ${row.rowNumber}. Revisar antes de enviar.`);\n } else {\n seenEmails.set(key, row.rowNumber);\n }\n }\n\n const nombreNormalizado = normalizeText(row.nombre);\n const hojaNormalizada = normalizeText(row.hoja);\n\n if (nombreNormalizado.includes('IRMA ALEJANDRA VENTURA') || hojaNormalizada === 'L29') {\n errores.push(`SEGURIDAD: Irma Alejandra Ventura / L29 aparece como LISTO_PARA_ENVIAR en la fila ${row.rowNumber}. No se permite continuar.`);\n }\n}\n\nif (errores.length > 0) {\n throw new Error('Validación detenida. Corrige estos puntos antes de enviar:\\n\\n' + errores.join('\\n'));\n}\n\nconst logoUrl = 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png';\n\nreturn candidatos.map(row => ({\n json: {\n ...row,\n logoUrl,\n subject: `${row.nombre} | Detalles de finiquito`,\n attachmentName: `${row.nombre} - Detalles de finiquito.pdf`\n }\n}));"
+ },
+ "id": "27af60fe-1661-41eb-98c0-470ee0dd82ad",
+ "name": "Validar y preparar envíos",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 912,
+ 2000
+ ],
+ "notesInFlow": true,
+ "notes": "Valida filas LISTO_PARA_ENVIAR, rechaza errores de correo/PDF, excluye Irma/L29 por seguridad y prepara un item por colaborador."
+ },
+ {
+ "parameters": {
+ "url": "=https://www.googleapis.com/drive/v3/files/{{$json.pdfFileId}}?alt=media&supportsAllDrives=true",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "googleDriveOAuth2Api",
+ "options": {
+ "response": {
+ "response": {
+ "responseFormat": "file"
+ }
+ }
+ }
+ },
+ "id": "7c7623b8-01db-4859-a450-09da467176b6",
+ "name": "Descargar PDF",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 1728,
+ 2016
+ ],
+ "retryOnFail": true,
+ "maxTries": 3,
+ "waitBetweenTries": 5000,
+ "credentials": {
+ "googleDriveOAuth2Api": {
+ "id": "g23xdGLZRzBGqKgH",
+ "name": "Isaac - Google Drive"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const items = $input.all();\n\nfunction escapeHtml(value) {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\nreturn items.map(item => {\n const source = item.json;\n const binary = item.binary || {};\n\n const nombre = escapeHtml(source.nombre);\n const pdfUrl = escapeHtml(source.pdfUrl || '#');\n const logoUrl = escapeHtml(source.logoUrl || 'https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png');\n\n const html = `\n\n\n
\n \n \n Detalles de finiquito\n\n\n\n\n \n Tu detalle de finiquito laboral está adjunto a este correo.\n
\n\n \n \n \n\n \n\n \n \n \n | \n \n\n \n | | \n \n\n \n \n\n \n Detalles de finiquito\n \n\n \n Hola ${nombre},\n \n\n \n Adjunto encontrarás el detalle de tu finiquito laboral correspondiente a tu proceso de finalización con GomezLee Marketing.\n \n\n \n Te pedimos por favor revisar la información con atención y aprobar por esta vía. Si tienes alguna duda o necesitas validar algún dato, puedes responder a este correo para que el equipo correspondiente pueda darte seguimiento. Luego de tu aprobación te estaremos contactando para el pago.\n \n\n \n El documento está adjunto a este correo. También puedes abrirlo desde el siguiente enlace:\n \n\n \n\n \n \n | \n Este documento contiene información confidencial. Por favor no lo compartas fuera de los canales correspondientes.\n | \n \n \n\n \n Saludos cordiales, \n GomezLee Marketing\n \n\n | \n \n\n \n\n | \n
\n
\n\n\n\n`;\n\n if (binary.data) {\n binary.data.fileName = source.attachmentName || `${source.nombre} - Detalles de finiquito.pdf`;\n binary.data.mimeType = 'application/pdf';\n }\n\n return {\n json: {\n ...source,\n html\n },\n binary\n };\n});"
+ },
+ "id": "2ec1eb01-e956-42a1-a0e8-d52cbc8c109b",
+ "name": "Construir HTML por empleado",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 2016,
+ 2016
+ ],
+ "notesInFlow": true,
+ "notes": "Construye el correo real para cada persona: Hola + nombre correspondiente, logo GLM real, botón y adjunto PDF."
+ },
+ {
+ "parameters": {
+ "sendTo": "={{$json[\"emailPersonal\"]}}",
+ "subject": "={{$json[\"subject\"]}}",
+ "message": "={{$json[\"html\"]}}",
+ "options": {
+ "attachmentsUi": {
+ "attachmentsBinary": [
+ {}
+ ]
+ },
+ "senderName": "Rocío Galvez",
+ "replyTo": "rgalvez@gomezleemarketing.com"
+ }
+ },
+ "id": "9cffb51e-ed58-42ce-8044-e41365053790",
+ "name": "Enviar correo a colaborador",
+ "type": "n8n-nodes-base.gmail",
+ "typeVersion": 2.1,
+ "position": [
+ 2288,
+ 2016
+ ],
+ "webhookId": "5d0344ac-b2d4-4279-a7d3-e5a3f12a282d",
+ "retryOnFail": true,
+ "maxTries": 3,
+ "waitBetweenTries": 5000,
+ "credentials": {
+ "gmailOAuth2": {
+ "id": "UDcO1FLJqA453V2D",
+ "name": "Gmail account 3"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const enviados = $input.all();\nconst fuentes = $('Construir HTML por empleado').all();\n\nif (enviados.length !== fuentes.length) {\n throw new Error(`La cantidad de correos enviados (${enviados.length}) no coincide con la cantidad preparada (${fuentes.length}).`);\n}\n\nreturn enviados.map((item, index) => {\n const source = fuentes[index].json;\n const fecha = new Date().toISOString();\n\n return {\n json: {\n ...source,\n gmailId: item.json.id || item.json.messageId || '',\n sentAt: fecha,\n observacionEnvio: `Correo enviado por n8n el ${fecha}. Acceso de lectura otorgado al PDF y documento adjunto.`\n }\n };\n});"
+ },
+ "id": "528e4272-351a-4e38-b9e8-704aeba503ad",
+ "name": "Preparar actualización de CONTROL_ENVIOS",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 3472,
+ 2000
+ ]
+ },
+ {
+ "parameters": {
+ "method": "PUT",
+ "url": "==https://sheets.googleapis.com/v4/spreadsheets/1Y9XhRjSSxtymV7Nm0gYbC5lLuGD2zPTc7IVb8PxoxjY/values/{{$json.estadoRange}}?valueInputOption=USER_ENTERED",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "googleSheetsOAuth2Api",
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ { values: [[ \"ENVIADO\" ]] } }}",
+ "options": {}
+ },
+ "id": "a3f1ea0c-8c3c-456d-ab7a-e1a6d0e10565",
+ "name": "Marcar ENVIADO",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 3744,
+ 1920
+ ],
+ "notesInFlow": true,
+ "retryOnFail": true,
+ "maxTries": 3,
+ "waitBetweenTries": 5000,
+ "credentials": {
+ "googleSheetsOAuth2Api": {
+ "id": "K0hDZh3a85MpOHCs",
+ "name": "Google Sheets account 2"
+ }
+ },
+ "notes": "Actualiza la columna Estado envío a ENVIADO solo después de que Gmail confirma el envío."
+ },
+ {
+ "parameters": {
+ "jsCode": "const fuentes = $('Preparar actualización de CONTROL_ENVIOS').all();\nconst respuestas = $input.all();\n\nif (respuestas.length !== fuentes.length) {\n throw new Error(`La cantidad de actualizaciones de estado (${respuestas.length}) no coincide con la cantidad enviada (${fuentes.length}).`);\n}\n\nreturn respuestas.map((item, index) => ({\n json: fuentes[index].json\n}));"
+ },
+ "id": "df873088-035e-4b3e-9d99-94b0d1d15e6f",
+ "name": "Restaurar datos tras marcar estado",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 4032,
+ 2000
+ ]
+ },
+ {
+ "parameters": {
+ "method": "PUT",
+ "url": "==https://sheets.googleapis.com/v4/spreadsheets/1Y9XhRjSSxtymV7Nm0gYbC5lLuGD2zPTc7IVb8PxoxjY/values/{{$json.observacionRange}}?valueInputOption=USER_ENTERED",
+ "authentication": "predefinedCredentialType",
+ "nodeCredentialType": "googleSheetsOAuth2Api",
+ "sendBody": true,
+ "specifyBody": "json",
+ "jsonBody": "={{ { values: [[ $json.observacionEnvio ]] } }}",
+ "options": {}
+ },
+ "id": "691abc7d-6ded-49aa-a805-d94efa62703a",
+ "name": "Actualizar observación",
+ "type": "n8n-nodes-base.httpRequest",
+ "typeVersion": 4.2,
+ "position": [
+ 4304,
+ 2000
+ ],
+ "retryOnFail": true,
+ "maxTries": 3,
+ "waitBetweenTries": 5000,
+ "credentials": {
+ "googleSheetsOAuth2Api": {
+ "id": "K0hDZh3a85MpOHCs",
+ "name": "Google Sheets account 2"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "const fuentes = $('Preparar actualización de CONTROL_ENVIOS').all();\n\nreturn [\n {\n json: {\n estado: 'ENVIO_MASIVO_COMPLETADO',\n totalCorreosEnviados: fuentes.length,\n enviados: fuentes.map(item => ({\n nombre: item.json.nombre,\n email: item.json.emailPersonal,\n hoja: item.json.hoja,\n pdfFileId: item.json.pdfFileId,\n gmailId: item.json.gmailId || ''\n })),\n nota: 'Se otorgó acceso de lectura al PDF antes de cada envío. Se actualizó CONTROL_ENVIOS a ENVIADO para los registros procesados.',\n fecha: new Date().toISOString()\n }\n }\n];"
+ },
+ "id": "ea9a0369-8b23-4816-a1f0-f41f2206fbcb",
+ "name": "Resumen final",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 4592,
+ 2000
+ ]
+ },
+ {
+ "parameters": {
+ "content": "## 1) Lectura y validación\n\nLee CONTROL_ENVIOS y prepara solo las filas con:\n\n**Estado envío = LISTO_PARA_ENVIAR**\n\nAquí se validan los datos obligatorios antes de continuar:\n\n- Nombre del colaborador\n- Email personal\n- PDF File ID\n- PDF URL\n- Estado de envío\n\nSi alguna fila tiene datos incompletos o inválidos, el flujo se detiene para evitar errores en el envío.",
+ "height": 464,
+ "width": 784
+ },
+ "type": "n8n-nodes-base.stickyNote",
+ "typeVersion": 1,
+ "position": [
+ 304,
+ 1712
+ ],
+ "id": "505b4875-c922-4b2b-8caf-5a2b543ee660",
+ "name": "Sticky Note"
+ },
+ {
+ "parameters": {
+ "content": "## 2) Acceso al PDF + correo personalizado\n\nPor cada colaborador, el flujo primero otorga acceso de lectura al PDF correspondiente.\n\nLuego descarga el PDF y construye el correo HTML personalizado con:\n\n**Hola [Nombre del colaborador],**\n\nCada persona recibe únicamente su propio documento adjunto y su propio enlace de Drive.",
+ "height": 384,
+ "width": 1312,
+ "color": 5
+ },
+ "type": "n8n-nodes-base.stickyNote",
+ "typeVersion": 1,
+ "position": [
+ 1440,
+ 1824
+ ],
+ "id": "23630222-28f7-4675-b6f5-0fe0c5951e5c",
+ "name": "Sticky Note1"
+ },
+ {
+ "parameters": {
+ "content": "## 3) Registro posterior al envío\n\nDespués de enviar el correo correctamente, el flujo actualiza CONTROL_ENVIOS.\n\nMarca la fila como:\n\n**ENVIADO**\n\nTambién actualiza la observación para dejar trazabilidad del envío.\n\nEjecutar este workflow únicamente cuando Administración / Nómina den el visto bueno final.",
+ "height": 528,
+ "width": 1520,
+ "color": 4
+ },
+ "type": "n8n-nodes-base.stickyNote",
+ "typeVersion": 1,
+ "position": [
+ 3280,
+ 1696
+ ],
+ "id": "9386eef4-0dd9-4651-ac46-550be3c703e7",
+ "name": "Sticky Note2"
+ }
+ ],
+ "connections": {
+ "Manual Trigger": {
+ "main": [
+ [
+ {
+ "node": "Leer CONTROL_ENVIOS",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Leer CONTROL_ENVIOS": {
+ "main": [
+ [
+ {
+ "node": "Validar y preparar envíos",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Validar y preparar envíos": {
+ "main": [
+ [
+ {
+ "node": "Descargar PDF",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Descargar PDF": {
+ "main": [
+ [
+ {
+ "node": "Construir HTML por empleado",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Construir HTML por empleado": {
+ "main": [
+ [
+ {
+ "node": "Enviar correo a colaborador",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Enviar correo a colaborador": {
+ "main": [
+ [
+ {
+ "node": "Preparar actualización de CONTROL_ENVIOS",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Preparar actualización de CONTROL_ENVIOS": {
+ "main": [
+ [
+ {
+ "node": "Marcar ENVIADO",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Marcar ENVIADO": {
+ "main": [
+ [
+ {
+ "node": "Restaurar datos tras marcar estado",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Restaurar datos tras marcar estado": {
+ "main": [
+ [
+ {
+ "node": "Actualizar observación",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Actualizar observación": {
+ "main": [
+ [
+ {
+ "node": "Resumen final",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "pinData": {},
+ "meta": {
+ "templateCredsSetupCompleted": true,
+ "instanceId": "b4b77b17af092830e794eef639ce2f6d7daccf7eddc075060b03b3b6545aac70"
+ }
+}
\ No newline at end of file