891 lines
47 KiB
JSON
891 lines
47 KiB
JSON
{
|
|
"updatedAt": "2026-08-16T19:46:37.104Z",
|
|
"createdAt": "2026-08-16T17:10:19.586Z",
|
|
"id": "zOosAqUTV2qS1oro",
|
|
"name": "Tablero CDC - Sync Registro Horas",
|
|
"description": null,
|
|
"active": true,
|
|
"isArchived": false,
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "tablero-cdc-sync-registro-horas",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2.1,
|
|
"position": [
|
|
496,
|
|
624
|
|
],
|
|
"id": "147264a4-72cc-4237-83bf-cc0c6574215d",
|
|
"name": "Webhook - Sync registro horas",
|
|
"webhookId": "b8e063ca-065b-4b29-848c-cfcbe98cc35c"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) { return String(value ?? '').trim(); }\n\nconst action = clean(body.action || 'sync_project').toLowerCase();\nconst projectId = clean(body.project_id || body.projectId);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nif (action !== 'sync_project') throw new Error('Acción no soportada.');\nif (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectId)) {\n throw new Error('Project ID inválido.');\n}\nif (!accessToken) throw new Error('No se recibió una sesión válida de Supabase.');\n\nreturn [{ json: {\n action,\n project_id: projectId,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc-time',\n received_at: new Date().toISOString(),\n} }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
720,
|
|
624
|
|
],
|
|
"id": "f3f6b85d-8ba0-40fe-ae6d-d9bac4d81f74",
|
|
"name": "Code - Preparar solicitud"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://dbit.digitalcompass.agency/auth/v1/user",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
944,
|
|
624
|
|
],
|
|
"id": "7f4c5187-b496-4929-bcab-b0af2b5076ce",
|
|
"name": "Supabase - Validar sesión",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,title,client,country,country_manager,created_at&limit=1' }}",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1168,
|
|
512
|
|
],
|
|
"id": "a45b14ed-76df-4ea5-8e18-fbe03a76c3e3",
|
|
"name": "Supabase - Leer proyecto",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_time_entries?project_id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,project_id,country,task_name,duration_minutes,work_date,notes,created_by_name,created_by_email,created_at&order=work_date.asc,created_at.asc&limit=5000' }}",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1376,
|
|
512
|
|
],
|
|
"id": "99d8832a-52f9-477a-a857-e5d3bce9811f",
|
|
"name": "Supabase - Leer tiempos proyecto",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const entries = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) entries.push(...raw);\n else if (raw && Array.isArray(raw.body)) entries.push(...raw.body);\n else if (raw && raw.id) entries.push(raw);\n}\nreturn [{ json: { entries } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1600,
|
|
512
|
|
],
|
|
"id": "dba4abe6-1dca-4bbe-8840-5a9c388687ae",
|
|
"name": "Code - Consolidar tiempos"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.country_manager&is_active=eq.true&select=value,label&limit=1000",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1824,
|
|
512
|
|
],
|
|
"id": "c46811a4-5a9d-4696-b083-2e85bdf945f9",
|
|
"name": "Supabase - Leer Country Managers",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const managers = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) managers.push(...raw);\n else if (raw && Array.isArray(raw.body)) managers.push(...raw.body);\n else if (raw && (raw.value || raw.label)) managers.push(raw);\n}\nreturn [{ json: { managers } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2048,
|
|
512
|
|
],
|
|
"id": "2d307078-a969-42b3-9e1d-5b47b02ddf2e",
|
|
"name": "Code - Consolidar Country Managers"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/%27Registro%20horas%27!B2:U20000?majorDimension=ROWS",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
2256,
|
|
512
|
|
],
|
|
"id": "b4ac9069-f9ed-4938-af31-f16ceb68143d",
|
|
"name": "Google Sheets API - Leer Registro horas",
|
|
"retryOnFail": true,
|
|
"maxTries": 5,
|
|
"waitBetweenTries": 5000,
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const sheetResponse = $input.first().json || {};\nconst values = Array.isArray(sheetResponse.values)\n ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : [])\n : [];\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction key(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction escSheetName(name) {\n return `'${String(name).replace(/'/g, \"''\")}'`;\n}\n\nfunction extractProjectFromNode(nodeName) {\n let items = [];\n try {\n items = $(nodeName).all();\n } catch (error) {\n items = [];\n }\n\n const candidates = [];\n\n for (const item of items) {\n const raw = item?.json;\n\n if (Array.isArray(raw)) {\n candidates.push(...raw);\n continue;\n }\n\n if (!raw || typeof raw !== 'object') continue;\n\n candidates.push(raw);\n\n if (Array.isArray(raw.body)) candidates.push(...raw.body);\n else if (raw.body && typeof raw.body === 'object') candidates.push(raw.body);\n\n if (Array.isArray(raw.data)) candidates.push(...raw.data);\n else if (raw.data && typeof raw.data === 'object') candidates.push(raw.data);\n\n if (raw.response && typeof raw.response === 'object') {\n if (Array.isArray(raw.response.body)) candidates.push(...raw.response.body);\n else if (raw.response.body && typeof raw.response.body === 'object') {\n candidates.push(raw.response.body);\n }\n }\n }\n\n return candidates.find(candidate =>\n candidate &&\n typeof candidate === 'object' &&\n clean(candidate.id)\n ) || null;\n}\n\nconst project = extractProjectFromNode('Supabase - Leer proyecto');\nconst entries = $('Code - Consolidar tiempos').first().json.entries || [];\nconst projectId = clean($('Code - Preparar solicitud').first().json.project_id);\n\nif (!project) {\n throw new Error(`No se encontró el proyecto ${projectId} en la respuesta de Supabase.`);\n}\n\nif (clean(project.id).toLowerCase() !== projectId.toLowerCase()) {\n throw new Error(`SEGURIDAD: Supabase devolvió un proyecto diferente al solicitado (${clean(project.id)}).`);\n}\n\n// La lectura empieza en B2, por eso los índices son relativos a B:U.\n// C, D, H, R y S son columnas calculadas/protegidas en el Sheet y NUNCA se escriben.\n// L:P son campos manuales y NUNCA se sobrescriben.\nconst header = values[0] || [];\nconst timeHeader = clean(header[18]); // T\nconst projectHeader = clean(header[19]); // U\nconst expectedTimeHeader = 'Tablero Time ID';\nconst expectedProjectHeader = 'Tablero Project ID';\n\nif (timeHeader && key(timeHeader) !== key(expectedTimeHeader)) {\n throw new Error(`SEGURIDAD: la columna T ya tiene el encabezado “${timeHeader}”. No se modificó Registro horas.`);\n}\n\nif (projectHeader && key(projectHeader) !== key(expectedProjectHeader)) {\n throw new Error(`SEGURIDAD: la columna U ya tiene el encabezado “${projectHeader}”. No se modificó Registro horas.`);\n}\n\nif ((!timeHeader || !projectHeader)) {\n for (let i = 1; i < values.length; i += 1) {\n const row = values[i] || [];\n if (clean(row[18]) || clean(row[19])) {\n throw new Error('SEGURIDAD: existen datos en T/U sin los encabezados de integración. No se modificó Registro horas.');\n }\n }\n}\n\nconst currentIds = new Set(entries.map(entry => clean(entry.id)).filter(Boolean));\nconst existingByEntryId = new Map();\nconst projectRows = [];\nconst blankRows = [];\n\n// Estas son las columnas que una persona puede completar manualmente o que pertenecen\n// a la integración. Si cualquiera tiene contenido, la fila NO se considera libre.\n// Se excluyen C,D,H,R,S porque son calculadas por el propio Sheet.\nconst meaningfulIndexes = [0, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19];\n\nfor (let rowNum = 3; rowNum <= 20000; rowNum += 1) {\n const row = values[rowNum - 2] || [];\n const timeId = clean(row[18]);\n const rowProjectId = clean(row[19]);\n\n if (timeId) {\n if (existingByEntryId.has(timeId)) {\n throw new Error(`SEGURIDAD: el Tablero Time ID ${timeId} aparece duplicado en Registro horas.`);\n }\n existingByEntryId.set(timeId, { rowNum, row });\n }\n\n if (rowProjectId === projectId) {\n projectRows.push({ rowNum, row, timeId });\n }\n\n const isTrulyBlank = meaningfulIndexes.every(index => !clean(row[index]));\n if (isTrulyBlank) {\n blankRows.push({ rowNum, row });\n }\n}\n\nconst staleRows = projectRows.filter(item => !item.timeId || !currentIds.has(item.timeId));\n\n// Una fila vieja solo se reutiliza si L:P están vacías. Si alguien completó algo manualmente,\n// se limpia únicamente lo que pertenece a Tablero CDC y esa fila se deja fuera del pool reutilizable.\nconst reusableStaleRows = staleRows\n .filter(item => [10, 11, 12, 13, 14].every(index => !clean(item.row[index])))\n .map(item => ({ rowNum: item.rowNum, row: item.row }));\n\nconst reusableRows = [...reusableStaleRows, ...blankRows];\nconst usedRows = new Set();\nconst data = [];\nconst sheet = escSheetName('Registro horas');\n\nfunction pushRange(range, rowValues) {\n data.push({\n range,\n majorDimension: 'ROWS',\n values: [rowValues],\n });\n}\n\nfunction clearOwnedColumns(rowNum) {\n // No tocamos columnas calculadas/protegidas (C,D,H,R,S) ni manuales (L:P).\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, ['']);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, ['']);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, ['', '']);\n}\n\nfunction writeEntry(rowNum, entry) {\n const entryId = clean(entry.id);\n const dateText = clean(entry.work_date);\n\n if (!entryId || !/^\\d{4}-\\d{2}-\\d{2}$/.test(dateText)) {\n throw new Error(`Registro de tiempo inválido: ${entryId || 'sin ID'}.`);\n }\n\n const [yearText, monthText, dayText] = dateText.split('-');\n const year = Number(yearText);\n const month = Number(monthText);\n const day = Number(dayText);\n const durationMinutes = Number(entry.duration_minutes || 0);\n\n if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {\n throw new Error(`Duración inválida en ${entryId}.`);\n }\n\n const country = clean(entry.country) || clean(project.country);\n const actor = clean(entry.created_by_name) || clean(entry.created_by_email);\n const task = clean(entry.task_name);\n const notes = clean(entry.notes);\n const hours = Math.round((durationMinutes / 60) * 10000) / 10000;\n const dateFormula = `=DATE(${year},${month},${day})`;\n\n // Solo escribimos columnas editables/no protegidas.\n // El Sheet calcula automáticamente Mes, Día, CM, # MES y AÑO.\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, [dateFormula]);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, [clean(project.title), clean(project.client), country]);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, [actor, task, hours]);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, [notes]);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, [entryId, projectId]);\n}\n\n// Inicializar los encabezados técnicos únicamente si faltan; no reescribirlos en cada ejecución.\nif (!timeHeader || !projectHeader) {\n pushRange(`${sheet}!T2:U2`, [expectedTimeHeader, expectedProjectHeader]);\n}\n\nfor (const stale of staleRows) {\n usedRows.add(stale.rowNum);\n clearOwnedColumns(stale.rowNum);\n}\n\nlet createdCount = 0;\nlet updatedCount = 0;\n\nfor (const entry of entries) {\n const entryId = clean(entry.id);\n const existing = existingByEntryId.get(entryId);\n let target;\n\n if (existing) {\n target = existing;\n updatedCount += 1;\n } else {\n target = reusableRows.find(candidate => !usedRows.has(candidate.rowNum));\n if (!target) {\n throw new Error('No quedan filas realmente libres en Registro horas hasta la fila 20000.');\n }\n createdCount += 1;\n }\n\n usedRows.add(target.rowNum);\n writeEntry(target.rowNum, entry);\n}\n\nreturn [{\n json: {\n data,\n project_id: projectId,\n total_entries: entries.length,\n created_rows: createdCount,\n updated_rows: updatedCount,\n cleared_rows: staleRows.length,\n first_available_row: blankRows.length ? blankRows[0].rowNum : null,\n write_strategy: 'segmented_unprotected_ranges',\n },\n}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2480,
|
|
512
|
|
],
|
|
"id": "9fae728c-885c-44e6-9327-5e8ad869b19d",
|
|
"name": "Code - Planificar sincronización"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ { valueInputOption: 'USER_ENTERED', data: $('Code - Planificar sincronización').first().json.data } }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
2720,
|
|
512
|
|
],
|
|
"id": "7ddca426-9515-40e0-93e0-ea970324c589",
|
|
"name": "Google Sheets API - Escribir Registro horas",
|
|
"retryOnFail": true,
|
|
"maxTries": 5,
|
|
"waitBetweenTries": 5000,
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{ { ok: true, message: 'Registro horas sincronizado correctamente.', projectId: $('Code - Planificar sincronización').first().json.project_id, syncedEntries: $('Code - Planificar sincronización').first().json.total_entries, createdRows: $('Code - Planificar sincronización').first().json.created_rows, updatedRows: $('Code - Planificar sincronización').first().json.updated_rows, clearedEntries: $('Code - Planificar sincronización').first().json.cleared_rows } }}",
|
|
"options": {
|
|
"responseCode": 200
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
2960,
|
|
512
|
|
],
|
|
"id": "d732c902-c587-4167-84b7-77f8196883d9",
|
|
"name": "Respond - Registro horas sincronizado"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"content": "## TABLERO CDC → REGISTRO HORAS — FIX COLUMNAS PROTEGIDAS\n\n• Sincroniza automáticamente el módulo **Tiempo** con `Registro horas`.\n• FIX: nunca escribe sobre las columnas calculadas/protegidas C, D, H, R y S.\n• Solo escribe B, E:G, I:K, Q y T:U; L:P se preservan porque son campos manuales.\n• Una fila solo se reutiliza si está realmente libre; ya no toma como libre una fila parcialmente completada (por ejemplo, con País/BU).\n• Crear, reintentar o resincronizar no duplica registros porque T/U conservan los IDs técnicos.\n• Si se elimina un tiempo, se limpian únicamente las columnas propiedad de Tablero CDC; los campos manuales nunca se borran automáticamente.\n• Google Sheets mantiene reintentos automáticos para errores temporales (503).",
|
|
"height": 300,
|
|
"width": 1280,
|
|
"color": 5
|
|
},
|
|
"type": "n8n-nodes-base.stickyNote",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
576,
|
|
16
|
|
],
|
|
"id": "65781b4a-63ef-482b-9b91-e4c60c5f50bd",
|
|
"name": "Sticky Note"
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook - Sync registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Preparar solicitud",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Preparar solicitud": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Validar sesión",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Validar sesión": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer proyecto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer proyecto": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer tiempos proyecto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer tiempos proyecto": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Consolidar tiempos",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Consolidar tiempos": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer Country Managers",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer Country Managers": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Consolidar Country Managers",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Consolidar Country Managers": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Google Sheets API - Leer Registro horas",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Google Sheets API - Leer Registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Planificar sincronización",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Planificar sincronización": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Google Sheets API - Escribir Registro horas",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Google Sheets API - Escribir Registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond - Registro horas sincronizado",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"binaryMode": "separate"
|
|
},
|
|
"staticData": null,
|
|
"meta": null,
|
|
"versionId": "b2b96202-a028-4084-a436-07b791eef620",
|
|
"activeVersionId": "b2b96202-a028-4084-a436-07b791eef620",
|
|
"versionCounter": 14,
|
|
"triggerCount": 1,
|
|
"shared": [
|
|
{
|
|
"updatedAt": "2026-08-16T17:10:19.594Z",
|
|
"createdAt": "2026-08-16T17:10:19.594Z",
|
|
"role": "workflow:owner",
|
|
"workflowId": "zOosAqUTV2qS1oro",
|
|
"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-16T19:43:08.000Z",
|
|
"createdAt": "2026-08-16T19:43:07.044Z",
|
|
"versionId": "b2b96202-a028-4084-a436-07b791eef620",
|
|
"workflowId": "zOosAqUTV2qS1oro",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "tablero-cdc-sync-registro-horas",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2.1,
|
|
"position": [
|
|
496,
|
|
624
|
|
],
|
|
"id": "147264a4-72cc-4237-83bf-cc0c6574215d",
|
|
"name": "Webhook - Sync registro horas",
|
|
"webhookId": "b8e063ca-065b-4b29-848c-cfcbe98cc35c"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const input = $json || {};\nconst body = input.body && typeof input.body === 'object' ? input.body : input;\n\nfunction clean(value) { return String(value ?? '').trim(); }\n\nconst action = clean(body.action || 'sync_project').toLowerCase();\nconst projectId = clean(body.project_id || body.projectId);\nconst accessToken = clean(body.access_token || body.accessToken);\n\nif (action !== 'sync_project') throw new Error('Acción no soportada.');\nif (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectId)) {\n throw new Error('Project ID inválido.');\n}\nif (!accessToken) throw new Error('No se recibió una sesión válida de Supabase.');\n\nreturn [{ json: {\n action,\n project_id: projectId,\n access_token: accessToken,\n source: clean(body.source) || 'tablero-cdc-time',\n received_at: new Date().toISOString(),\n} }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
720,
|
|
624
|
|
],
|
|
"id": "f3f6b85d-8ba0-40fe-ae6d-d9bac4d81f74",
|
|
"name": "Code - Preparar solicitud"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://dbit.digitalcompass.agency/auth/v1/user",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
944,
|
|
624
|
|
],
|
|
"id": "7f4c5187-b496-4929-bcab-b0af2b5076ce",
|
|
"name": "Supabase - Validar sesión",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_projects?id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,title,client,country,country_manager,created_at&limit=1' }}",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1168,
|
|
512
|
|
],
|
|
"id": "a45b14ed-76df-4ea5-8e18-fbe03a76c3e3",
|
|
"name": "Supabase - Leer proyecto",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "={{ 'https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_project_time_entries?project_id=eq.' + encodeURIComponent($('Code - Preparar solicitud').first().json.project_id) + '&select=id,project_id,country,task_name,duration_minutes,work_date,notes,created_by_name,created_by_email,created_at&order=work_date.asc,created_at.asc&limit=5000' }}",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1376,
|
|
512
|
|
],
|
|
"id": "99d8832a-52f9-477a-a857-e5d3bce9811f",
|
|
"name": "Supabase - Leer tiempos proyecto",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const entries = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) entries.push(...raw);\n else if (raw && Array.isArray(raw.body)) entries.push(...raw.body);\n else if (raw && raw.id) entries.push(raw);\n}\nreturn [{ json: { entries } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1600,
|
|
512
|
|
],
|
|
"id": "dba4abe6-1dca-4bbe-8840-5a9c388687ae",
|
|
"name": "Code - Consolidar tiempos"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_app_lists?category=eq.country_manager&is_active=eq.true&select=value,label&limit=1000",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "apikey",
|
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
|
},
|
|
{
|
|
"name": "Authorization",
|
|
"value": "={{ 'Bearer ' + $('Code - Preparar solicitud').first().json.access_token }}"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
1824,
|
|
512
|
|
],
|
|
"id": "c46811a4-5a9d-4696-b083-2e85bdf945f9",
|
|
"name": "Supabase - Leer Country Managers",
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 1200,
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const managers = [];\nfor (const item of $input.all()) {\n const raw = item.json;\n if (Array.isArray(raw)) managers.push(...raw);\n else if (raw && Array.isArray(raw.body)) managers.push(...raw.body);\n else if (raw && (raw.value || raw.label)) managers.push(raw);\n}\nreturn [{ json: { managers } }];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2048,
|
|
512
|
|
],
|
|
"id": "2d307078-a969-42b3-9e1d-5b47b02ddf2e",
|
|
"name": "Code - Consolidar Country Managers"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values/%27Registro%20horas%27!B2:U20000?majorDimension=ROWS",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
2256,
|
|
512
|
|
],
|
|
"id": "b4ac9069-f9ed-4938-af31-f16ceb68143d",
|
|
"name": "Google Sheets API - Leer Registro horas",
|
|
"retryOnFail": true,
|
|
"maxTries": 5,
|
|
"waitBetweenTries": 5000,
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const sheetResponse = $input.first().json || {};\nconst values = Array.isArray(sheetResponse.values)\n ? sheetResponse.values.map(row => Array.isArray(row) ? [...row] : [])\n : [];\n\nfunction clean(value) {\n return String(value ?? '').trim().replace(/\\s+/g, ' ');\n}\n\nfunction key(value) {\n return clean(value)\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .toLowerCase();\n}\n\nfunction escSheetName(name) {\n return `'${String(name).replace(/'/g, \"''\")}'`;\n}\n\nfunction extractProjectFromNode(nodeName) {\n let items = [];\n try {\n items = $(nodeName).all();\n } catch (error) {\n items = [];\n }\n\n const candidates = [];\n\n for (const item of items) {\n const raw = item?.json;\n\n if (Array.isArray(raw)) {\n candidates.push(...raw);\n continue;\n }\n\n if (!raw || typeof raw !== 'object') continue;\n\n candidates.push(raw);\n\n if (Array.isArray(raw.body)) candidates.push(...raw.body);\n else if (raw.body && typeof raw.body === 'object') candidates.push(raw.body);\n\n if (Array.isArray(raw.data)) candidates.push(...raw.data);\n else if (raw.data && typeof raw.data === 'object') candidates.push(raw.data);\n\n if (raw.response && typeof raw.response === 'object') {\n if (Array.isArray(raw.response.body)) candidates.push(...raw.response.body);\n else if (raw.response.body && typeof raw.response.body === 'object') {\n candidates.push(raw.response.body);\n }\n }\n }\n\n return candidates.find(candidate =>\n candidate &&\n typeof candidate === 'object' &&\n clean(candidate.id)\n ) || null;\n}\n\nconst project = extractProjectFromNode('Supabase - Leer proyecto');\nconst entries = $('Code - Consolidar tiempos').first().json.entries || [];\nconst projectId = clean($('Code - Preparar solicitud').first().json.project_id);\n\nif (!project) {\n throw new Error(`No se encontró el proyecto ${projectId} en la respuesta de Supabase.`);\n}\n\nif (clean(project.id).toLowerCase() !== projectId.toLowerCase()) {\n throw new Error(`SEGURIDAD: Supabase devolvió un proyecto diferente al solicitado (${clean(project.id)}).`);\n}\n\n// La lectura empieza en B2, por eso los índices son relativos a B:U.\n// C, D, H, R y S son columnas calculadas/protegidas en el Sheet y NUNCA se escriben.\n// L:P son campos manuales y NUNCA se sobrescriben.\nconst header = values[0] || [];\nconst timeHeader = clean(header[18]); // T\nconst projectHeader = clean(header[19]); // U\nconst expectedTimeHeader = 'Tablero Time ID';\nconst expectedProjectHeader = 'Tablero Project ID';\n\nif (timeHeader && key(timeHeader) !== key(expectedTimeHeader)) {\n throw new Error(`SEGURIDAD: la columna T ya tiene el encabezado “${timeHeader}”. No se modificó Registro horas.`);\n}\n\nif (projectHeader && key(projectHeader) !== key(expectedProjectHeader)) {\n throw new Error(`SEGURIDAD: la columna U ya tiene el encabezado “${projectHeader}”. No se modificó Registro horas.`);\n}\n\nif ((!timeHeader || !projectHeader)) {\n for (let i = 1; i < values.length; i += 1) {\n const row = values[i] || [];\n if (clean(row[18]) || clean(row[19])) {\n throw new Error('SEGURIDAD: existen datos en T/U sin los encabezados de integración. No se modificó Registro horas.');\n }\n }\n}\n\nconst currentIds = new Set(entries.map(entry => clean(entry.id)).filter(Boolean));\nconst existingByEntryId = new Map();\nconst projectRows = [];\nconst blankRows = [];\n\n// Estas son las columnas que una persona puede completar manualmente o que pertenecen\n// a la integración. Si cualquiera tiene contenido, la fila NO se considera libre.\n// Se excluyen C,D,H,R,S porque son calculadas por el propio Sheet.\nconst meaningfulIndexes = [0, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19];\n\nfor (let rowNum = 3; rowNum <= 20000; rowNum += 1) {\n const row = values[rowNum - 2] || [];\n const timeId = clean(row[18]);\n const rowProjectId = clean(row[19]);\n\n if (timeId) {\n if (existingByEntryId.has(timeId)) {\n throw new Error(`SEGURIDAD: el Tablero Time ID ${timeId} aparece duplicado en Registro horas.`);\n }\n existingByEntryId.set(timeId, { rowNum, row });\n }\n\n if (rowProjectId === projectId) {\n projectRows.push({ rowNum, row, timeId });\n }\n\n const isTrulyBlank = meaningfulIndexes.every(index => !clean(row[index]));\n if (isTrulyBlank) {\n blankRows.push({ rowNum, row });\n }\n}\n\nconst staleRows = projectRows.filter(item => !item.timeId || !currentIds.has(item.timeId));\n\n// Una fila vieja solo se reutiliza si L:P están vacías. Si alguien completó algo manualmente,\n// se limpia únicamente lo que pertenece a Tablero CDC y esa fila se deja fuera del pool reutilizable.\nconst reusableStaleRows = staleRows\n .filter(item => [10, 11, 12, 13, 14].every(index => !clean(item.row[index])))\n .map(item => ({ rowNum: item.rowNum, row: item.row }));\n\nconst reusableRows = [...reusableStaleRows, ...blankRows];\nconst usedRows = new Set();\nconst data = [];\nconst sheet = escSheetName('Registro horas');\n\nfunction pushRange(range, rowValues) {\n data.push({\n range,\n majorDimension: 'ROWS',\n values: [rowValues],\n });\n}\n\nfunction clearOwnedColumns(rowNum) {\n // No tocamos columnas calculadas/protegidas (C,D,H,R,S) ni manuales (L:P).\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, ['']);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, ['', '', '']);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, ['']);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, ['', '']);\n}\n\nfunction writeEntry(rowNum, entry) {\n const entryId = clean(entry.id);\n const dateText = clean(entry.work_date);\n\n if (!entryId || !/^\\d{4}-\\d{2}-\\d{2}$/.test(dateText)) {\n throw new Error(`Registro de tiempo inválido: ${entryId || 'sin ID'}.`);\n }\n\n const [yearText, monthText, dayText] = dateText.split('-');\n const year = Number(yearText);\n const month = Number(monthText);\n const day = Number(dayText);\n const durationMinutes = Number(entry.duration_minutes || 0);\n\n if (!Number.isFinite(durationMinutes) || durationMinutes <= 0) {\n throw new Error(`Duración inválida en ${entryId}.`);\n }\n\n const country = clean(entry.country) || clean(project.country);\n const actor = clean(entry.created_by_name) || clean(entry.created_by_email);\n const task = clean(entry.task_name);\n const notes = clean(entry.notes);\n const hours = Math.round((durationMinutes / 60) * 10000) / 10000;\n const dateFormula = `=DATE(${year},${month},${day})`;\n\n // Solo escribimos columnas editables/no protegidas.\n // El Sheet calcula automáticamente Mes, Día, CM, # MES y AÑO.\n pushRange(`${sheet}!B${rowNum}:B${rowNum}`, [dateFormula]);\n pushRange(`${sheet}!E${rowNum}:G${rowNum}`, [clean(project.title), clean(project.client), country]);\n pushRange(`${sheet}!I${rowNum}:K${rowNum}`, [actor, task, hours]);\n pushRange(`${sheet}!Q${rowNum}:Q${rowNum}`, [notes]);\n pushRange(`${sheet}!T${rowNum}:U${rowNum}`, [entryId, projectId]);\n}\n\n// Inicializar los encabezados técnicos únicamente si faltan; no reescribirlos en cada ejecución.\nif (!timeHeader || !projectHeader) {\n pushRange(`${sheet}!T2:U2`, [expectedTimeHeader, expectedProjectHeader]);\n}\n\nfor (const stale of staleRows) {\n usedRows.add(stale.rowNum);\n clearOwnedColumns(stale.rowNum);\n}\n\nlet createdCount = 0;\nlet updatedCount = 0;\n\nfor (const entry of entries) {\n const entryId = clean(entry.id);\n const existing = existingByEntryId.get(entryId);\n let target;\n\n if (existing) {\n target = existing;\n updatedCount += 1;\n } else {\n target = reusableRows.find(candidate => !usedRows.has(candidate.rowNum));\n if (!target) {\n throw new Error('No quedan filas realmente libres en Registro horas hasta la fila 20000.');\n }\n createdCount += 1;\n }\n\n usedRows.add(target.rowNum);\n writeEntry(target.rowNum, entry);\n}\n\nreturn [{\n json: {\n data,\n project_id: projectId,\n total_entries: entries.length,\n created_rows: createdCount,\n updated_rows: updatedCount,\n cleared_rows: staleRows.length,\n first_available_row: blankRows.length ? blankRows[0].rowNum : null,\n write_strategy: 'segmented_unprotected_ranges',\n },\n}];"
|
|
},
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
2480,
|
|
512
|
|
],
|
|
"id": "9fae728c-885c-44e6-9327-5e8ad869b19d",
|
|
"name": "Code - Planificar sincronización"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://sheets.googleapis.com/v4/spreadsheets/19oBoslErcTSk8ibA6Nr38lcOV8Q09JwGahS7JD1o4Ts/values:batchUpdate",
|
|
"authentication": "predefinedCredentialType",
|
|
"nodeCredentialType": "googleSheetsOAuth2Api",
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ { valueInputOption: 'USER_ENTERED', data: $('Code - Planificar sincronización').first().json.data } }}",
|
|
"options": {}
|
|
},
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.4,
|
|
"position": [
|
|
2720,
|
|
512
|
|
],
|
|
"id": "7ddca426-9515-40e0-93e0-ea970324c589",
|
|
"name": "Google Sheets API - Escribir Registro horas",
|
|
"retryOnFail": true,
|
|
"maxTries": 5,
|
|
"waitBetweenTries": 5000,
|
|
"credentials": {
|
|
"googleSheetsOAuth2Api": {
|
|
"id": "K0hDZh3a85MpOHCs",
|
|
"name": "Google Sheets account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{ { ok: true, message: 'Registro horas sincronizado correctamente.', projectId: $('Code - Planificar sincronización').first().json.project_id, syncedEntries: $('Code - Planificar sincronización').first().json.total_entries, createdRows: $('Code - Planificar sincronización').first().json.created_rows, updatedRows: $('Code - Planificar sincronización').first().json.updated_rows, clearedEntries: $('Code - Planificar sincronización').first().json.cleared_rows } }}",
|
|
"options": {
|
|
"responseCode": 200
|
|
}
|
|
},
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.5,
|
|
"position": [
|
|
2960,
|
|
512
|
|
],
|
|
"id": "d732c902-c587-4167-84b7-77f8196883d9",
|
|
"name": "Respond - Registro horas sincronizado"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"content": "## TABLERO CDC → REGISTRO HORAS — FIX COLUMNAS PROTEGIDAS\n\n• Sincroniza automáticamente el módulo **Tiempo** con `Registro horas`.\n• FIX: nunca escribe sobre las columnas calculadas/protegidas C, D, H, R y S.\n• Solo escribe B, E:G, I:K, Q y T:U; L:P se preservan porque son campos manuales.\n• Una fila solo se reutiliza si está realmente libre; ya no toma como libre una fila parcialmente completada (por ejemplo, con País/BU).\n• Crear, reintentar o resincronizar no duplica registros porque T/U conservan los IDs técnicos.\n• Si se elimina un tiempo, se limpian únicamente las columnas propiedad de Tablero CDC; los campos manuales nunca se borran automáticamente.\n• Google Sheets mantiene reintentos automáticos para errores temporales (503).",
|
|
"height": 300,
|
|
"width": 1280,
|
|
"color": 5
|
|
},
|
|
"type": "n8n-nodes-base.stickyNote",
|
|
"typeVersion": 1,
|
|
"position": [
|
|
576,
|
|
16
|
|
],
|
|
"id": "65781b4a-63ef-482b-9b91-e4c60c5f50bd",
|
|
"name": "Sticky Note"
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook - Sync registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Preparar solicitud",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Preparar solicitud": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Validar sesión",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Validar sesión": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer proyecto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer proyecto": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer tiempos proyecto",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer tiempos proyecto": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Consolidar tiempos",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Consolidar tiempos": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Supabase - Leer Country Managers",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Supabase - Leer Country Managers": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Consolidar Country Managers",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Consolidar Country Managers": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Google Sheets API - Leer Registro horas",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Google Sheets API - Leer Registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Code - Planificar sincronización",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Code - Planificar sincronización": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Google Sheets API - Escribir Registro horas",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Google Sheets API - Escribir Registro horas": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Respond - Registro horas sincronizado",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"authors": "Isaac Aracena",
|
|
"name": "Version b2b96202",
|
|
"description": "",
|
|
"autosaved": false,
|
|
"workflowPublishHistory": [
|
|
{
|
|
"createdAt": "2026-08-16T19:43:08.063Z",
|
|
"id": 4841,
|
|
"workflowId": "zOosAqUTV2qS1oro",
|
|
"versionId": "b2b96202-a028-4084-a436-07b791eef620",
|
|
"event": "activated",
|
|
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
|
|
}
|
|
]
|
|
}
|
|
} |