{ "updatedAt": "2026-06-25T03:30:17.313Z", "createdAt": "2026-06-20T14:15:17.560Z", "id": "7eyfZEkGuDSUodDX", "name": "Tablero CDC - Sync Tarifario Sheet a Supabase", "description": null, "active": true, "isArchived": false, "nodes": [ { "parameters": { "jsCode": "function cleanText(value) {\n return String(value ?? \"\").trim();\n}\n\nfunction normalizeForCompare(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\");\n}\n\nfunction slugify(value) {\n return String(value ?? \"\")\n .trim()\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/&/g, \"y\")\n .replace(/ñ/g, \"n\")\n .replace(/[^a-z0-9]+/g, \"_\")\n .replace(/^_+|_+$/g, \"\");\n}\n\nfunction parseAmountRange(value) {\n const raw = cleanText(value);\n\n if (!raw) {\n return {\n reference: \"\",\n reference_min: null,\n reference_max: null,\n };\n }\n\n const reference = raw.replace(/\\s+/g, \" \");\n\n const withoutCurrency = reference\n .replace(/USD/gi, \"\")\n .replace(/US\\$/gi, \"\")\n .replace(/\\$/g, \"\")\n .replace(/\\+/g, \"\")\n .trim();\n\n const matches = withoutCurrency.match(/-?\\d[\\d,]*(\\.\\d+)?/g) || [];\n\n const numbers = matches\n .map((match) => Number(match.replace(/,/g, \"\")))\n .filter((number) => Number.isFinite(number));\n\n if (numbers.length === 0) {\n return {\n reference,\n reference_min: null,\n reference_max: null,\n };\n }\n\n if (numbers.length === 1) {\n return {\n reference,\n reference_min: numbers[0],\n reference_max: numbers[0],\n };\n }\n\n const min = Math.min(numbers[0], numbers[1]);\n const max = Math.max(numbers[0], numbers[1]);\n\n return {\n reference,\n reference_min: min,\n reference_max: max,\n };\n}\n\nfunction normalizeSection(value) {\n const raw = normalizeForCompare(value);\n\n if (!raw) {\n throw new Error(\"La columna Sección está vacía en una fila del tarifario.\");\n }\n\n if (raw === \"tarifario grafico cdc\") {\n return \"grafico\";\n }\n\n if (raw === \"estrategia y creatividad\") {\n return \"estrategia\";\n }\n\n throw new Error(\n `Sección inválida en tarifario: \"${value}\". Usa solo \"Tarifario Gráfico CDC\" o \"Estrategia y Creatividad\".`\n );\n}\n\nfunction buildBaseId(row, section, category, service, workTypeId) {\n const rawCodigoBase = cleanText(row[\"Código base\"]);\n\n if (rawCodigoBase) {\n return slugify(rawCodigoBase);\n }\n\n return slugify(\n [section, category, service, workTypeId].filter(Boolean).join(\"_\")\n );\n}\n\nfunction getSourceRowNumber(row, index) {\n return (\n row.row_number ||\n row.rowNumber ||\n row.__rowNumber ||\n index + 2\n );\n}\n\nconst records = [];\nconst generatedCodeUpdates = [];\n\nitems.forEach((item, index) => {\n const row = item.json;\n\n const rawSection = cleanText(row[\"Sección\"]);\n const rawCategory = cleanText(row[\"Categoría\"]);\n const rawService = cleanText(row[\"Servicio / ítem\"]);\n const rawWorkType = cleanText(row[\"Tipo de trabajo\"] || \"Referencia\");\n const shortLabel = cleanText(row[\"Etiqueta corta\"] || \"Ref.\");\n const hourReference = cleanText(row[\"Hora hombre ref.\"]);\n const notes = cleanText(row[\"Observaciones / incluye\"]);\n\n // Ignora filas totalmente vacías.\n const hasAnyBusinessData = [\n rawSection,\n rawCategory,\n rawService,\n rawWorkType,\n row[\"Nivel 1 nombre\"],\n row[\"Nivel 1 monto/rango\"],\n row[\"Nivel 2 nombre\"],\n row[\"Nivel 2 monto/rango\"],\n row[\"Nivel 3 nombre\"],\n row[\"Nivel 3 monto/rango\"],\n ].some((value) => cleanText(value));\n\n if (!hasAnyBusinessData) {\n return;\n }\n\n const section = normalizeSection(rawSection);\n const category = rawCategory;\n const service = rawService;\n const workTypeLabel = rawWorkType;\n const workTypeId = slugify(workTypeLabel || \"reference\") || \"reference\";\n\n if (!category || !service) {\n throw new Error(\n `Fila ${getSourceRowNumber(row, index)} incompleta: Categoría y Servicio / ítem son obligatorios.`\n );\n }\n\n const catalogItemId = slugify(\n [section, category, service].filter(Boolean).join(\"_\")\n );\n\n const baseId = buildBaseId(row, section, category, service, workTypeId);\n\n if (!baseId) {\n throw new Error(`No se pudo generar Código base en la fila ${getSourceRowNumber(row, index)}.`);\n }\n\n const sourceRowNumber = getSourceRowNumber(row, index);\n\n if (!cleanText(row[\"Código base\"])) {\n generatedCodeUpdates.push({\n row_number: sourceRowNumber,\n codigo_base: baseId,\n });\n }\n\n const levels = [\n {\n number: 1,\n name: cleanText(row[\"Nivel 1 nombre\"]),\n amountRange: cleanText(row[\"Nivel 1 monto/rango\"]),\n note: cleanText(row[\"Nivel 1 nota\"]),\n },\n {\n number: 2,\n name: cleanText(row[\"Nivel 2 nombre\"]),\n amountRange: cleanText(row[\"Nivel 2 monto/rango\"]),\n note: cleanText(row[\"Nivel 2 nota\"]),\n },\n {\n number: 3,\n name: cleanText(row[\"Nivel 3 nombre\"]),\n amountRange: cleanText(row[\"Nivel 3 monto/rango\"]),\n note: cleanText(row[\"Nivel 3 nota\"]),\n },\n ];\n\n const validLevels = levels.filter((level) => level.name && level.amountRange);\n\n if (validLevels.length === 0) {\n throw new Error(\n `Fila ${sourceRowNumber}: agrega al menos un nivel con nombre y monto/rango.`\n );\n }\n\n for (const level of validLevels) {\n const levelLabel = level.name;\n const levelId = slugify(levelLabel || \"project\") || \"project\";\n const amount = parseAmountRange(level.amountRange);\n\n const id = slugify(\n [baseId, levelId].filter(Boolean).join(\"_\")\n );\n\n records.push({\n id,\n catalog_item_id: catalogItemId,\n section,\n category,\n service,\n notes,\n work_type_id: workTypeId,\n work_type_label: workTypeLabel,\n work_type_short_label: shortLabel,\n hour_reference: hourReference,\n level_id: levelId,\n level_label: levelLabel,\n reference: amount.reference,\n reference_min: amount.reference_min,\n reference_max: amount.reference_max,\n level_hint: level.note,\n sort_order: 10000 + index * 10 + level.number,\n is_active: true,\n });\n }\n});\n\nif (records.length === 0) {\n throw new Error(\"No se encontraron tarifas válidas. Se detiene para no afectar el catálogo.\");\n}\n\nconst seen = new Set();\n\nfor (const record of records) {\n if (seen.has(record.id)) {\n throw new Error(`Código único duplicado detectado en tarifario: ${record.id}`);\n }\n\n seen.add(record.id);\n\n if (\n record.reference_min !== null &&\n record.reference_max !== null &&\n record.reference_min > record.reference_max\n ) {\n throw new Error(`La tarifa ${record.id} tiene mínimo mayor que máximo.`);\n }\n}\n\nreturn [\n {\n json: {\n total_source_rows: items.length,\n total_records_to_sync: records.length,\n total_generated_codes: generatedCodeUpdates.length,\n records,\n generated_code_updates: generatedCodeUpdates,\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 416, 0 ], "id": "25030323-2bde-460c-b1ad-02e6260d67da", "name": "Code - Normalizar Tarifario" }, { "parameters": { "method": "POST", "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_tariff_catalog?on_conflict=id", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "resolution=merge-duplicates,return=representation" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.records) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 624, 0 ], "id": "a21e1648-fd8c-4cf3-acc3-50f91af1e4d9", "name": "Supabase - Upsert Tarifario" }, { "parameters": { "documentId": { "__rl": true, "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", "mode": "list", "cachedResultName": "Tarifario de Creatividad - Sep 2004", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" }, "sheetName": { "__rl": true, "value": 1041164995, "mode": "list", "cachedResultName": "Catálogo Tarifario App", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" }, "options": { "dataLocationOnSheet": { "values": { "rangeDefinition": "detectAutomatically" } } } }, "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 208, 0 ], "id": "e6d4f9ef-30da-4640-9002-96b43aa48729", "name": "Google Sheets - Leer Catálogo Tarifario App", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const source = $('Code - Normalizar Tarifario').first().json;\n\nconst updates = source.generated_code_updates || [];\n\nreturn updates.map((update) => ({\n json: {\n row_number: update.row_number,\n codigo_base: update.codigo_base,\n },\n}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 832, 0 ], "id": "247f2f76-a1e4-4e51-b35f-a32bdaadf354", "name": "Code - Preparar códigos generados" }, { "parameters": { "operation": "update", "documentId": { "__rl": true, "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", "mode": "list", "cachedResultName": "Tarifario de Creatividad - Sep 2004", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" }, "sheetName": { "__rl": true, "value": 1041164995, "mode": "list", "cachedResultName": "Catálogo Tarifario App", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" }, "columns": { "mappingMode": "defineBelow", "value": { "row_number": "={{ $json[\"row_number\"] }}", "Código base": "={{ $json[\"codigo_base\"] }}" }, "matchingColumns": [ "row_number" ], "schema": [ { "id": "Código base", "displayName": "Código base", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": false }, { "id": "Sección", "displayName": "Sección", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Categoría", "displayName": "Categoría", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Servicio / ítem", "displayName": "Servicio / ítem", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Tipo de trabajo", "displayName": "Tipo de trabajo", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Etiqueta corta", "displayName": "Etiqueta corta", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Hora hombre ref.", "displayName": "Hora hombre ref.", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 nombre", "displayName": "Nivel 1 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 monto/rango", "displayName": "Nivel 1 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 nota", "displayName": "Nivel 1 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 nombre", "displayName": "Nivel 2 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 monto/rango", "displayName": "Nivel 2 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 nota", "displayName": "Nivel 2 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 nombre", "displayName": "Nivel 3 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 monto/rango", "displayName": "Nivel 3 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 nota", "displayName": "Nivel 3 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Observaciones / incluye", "displayName": "Observaciones / incluye", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "row_number", "displayName": "row_number", "required": false, "defaultMatch": false, "display": true, "type": "number", "canBeUsedToMatch": true, "readOnly": true, "removed": false } ], "attemptToConvertTypes": false, "convertFieldsToString": false }, "options": {} }, "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 1072, 0 ], "id": "60367c04-79f6-4c8a-99d1-bc9369c8ca61", "name": "Google Sheets - Escribir códigos únicos faltantes", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "rule": { "interval": [ { "field": "hours", "hoursInterval": 8 } ] } }, "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ -16, 0 ], "id": "1a45e645-65af-4727-bf4f-15c7000ba2a2", "name": "Schedule Trigger" }, { "parameters": { "content": "## 💵 SYNC TARIFARIO CDC → SUPABASE\n\nEste flujo sincroniza automáticamente el tarifario editable desde Google Sheets hacia Supabase para que la app Tablero CDC use el catálogo actualizado.\n\nFrecuencia:\n- Cada 8 horas.\n\nProceso:\n1. Lee el Sheet “Catálogo Tarifario App”.\n2. Normaliza sección, categoría, servicio, tipo de trabajo y niveles.\n3. Convierte “monto/rango” en reference, reference_min y reference_max.\n4. Genera códigos únicos cuando faltan.\n5. Hace upsert en Supabase: tablero_cdc_tariff_catalog.\n6. Escribe de vuelta en el Sheet los códigos base generados.\n\nReglas importantes:\n- No sincroniza filas vacías.\n- Cada fila debe tener categoría, servicio y al menos un nivel con monto/rango.\n- Si hay códigos duplicados o secciones inválidas, el flujo se detiene para proteger el catálogo.\n- El Sheet es la fuente humana editable; Supabase es la fuente usada por la app.", "height": 640, "width": 1408, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -64, -432 ], "id": "23c63e0a-2e95-4ec5-b075-d08ae68754fc", "name": "Sticky Note" } ], "connections": { "Code - Normalizar Tarifario": { "main": [ [ { "node": "Supabase - Upsert Tarifario", "type": "main", "index": 0 } ] ] }, "Supabase - Upsert Tarifario": { "main": [ [ { "node": "Code - Preparar códigos generados", "type": "main", "index": 0 } ] ] }, "Google Sheets - Leer Catálogo Tarifario App": { "main": [ [ { "node": "Code - Normalizar Tarifario", "type": "main", "index": 0 } ] ] }, "Code - Preparar códigos generados": { "main": [ [ { "node": "Google Sheets - Escribir códigos únicos faltantes", "type": "main", "index": 0 } ] ] }, "Schedule Trigger": { "main": [ [ { "node": "Google Sheets - Leer Catálogo Tarifario App", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1", "binaryMode": "separate" }, "staticData": { "node:Schedule Trigger": { "recurrenceRules": [ 16 ] } }, "meta": { "templateCredsSetupCompleted": true }, "versionId": "4ea3c2ee-0518-4a55-8c9a-ccffb26f471a", "activeVersionId": "4ea3c2ee-0518-4a55-8c9a-ccffb26f471a", "versionCounter": 116, "triggerCount": 1, "shared": [ { "updatedAt": "2026-06-20T14:15:17.562Z", "createdAt": "2026-06-20T14:15:17.562Z", "role": "workflow:owner", "workflowId": "7eyfZEkGuDSUodDX", "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-06-25T03:30:22.000Z", "createdAt": "2026-06-25T03:30:17.315Z", "versionId": "4ea3c2ee-0518-4a55-8c9a-ccffb26f471a", "workflowId": "7eyfZEkGuDSUodDX", "nodes": [ { "parameters": { "jsCode": "function cleanText(value) {\n return String(value ?? \"\").trim();\n}\n\nfunction normalizeForCompare(value) {\n return cleanText(value)\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\");\n}\n\nfunction slugify(value) {\n return String(value ?? \"\")\n .trim()\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/&/g, \"y\")\n .replace(/ñ/g, \"n\")\n .replace(/[^a-z0-9]+/g, \"_\")\n .replace(/^_+|_+$/g, \"\");\n}\n\nfunction parseAmountRange(value) {\n const raw = cleanText(value);\n\n if (!raw) {\n return {\n reference: \"\",\n reference_min: null,\n reference_max: null,\n };\n }\n\n const reference = raw.replace(/\\s+/g, \" \");\n\n const withoutCurrency = reference\n .replace(/USD/gi, \"\")\n .replace(/US\\$/gi, \"\")\n .replace(/\\$/g, \"\")\n .replace(/\\+/g, \"\")\n .trim();\n\n const matches = withoutCurrency.match(/-?\\d[\\d,]*(\\.\\d+)?/g) || [];\n\n const numbers = matches\n .map((match) => Number(match.replace(/,/g, \"\")))\n .filter((number) => Number.isFinite(number));\n\n if (numbers.length === 0) {\n return {\n reference,\n reference_min: null,\n reference_max: null,\n };\n }\n\n if (numbers.length === 1) {\n return {\n reference,\n reference_min: numbers[0],\n reference_max: numbers[0],\n };\n }\n\n const min = Math.min(numbers[0], numbers[1]);\n const max = Math.max(numbers[0], numbers[1]);\n\n return {\n reference,\n reference_min: min,\n reference_max: max,\n };\n}\n\nfunction normalizeSection(value) {\n const raw = normalizeForCompare(value);\n\n if (!raw) {\n throw new Error(\"La columna Sección está vacía en una fila del tarifario.\");\n }\n\n if (raw === \"tarifario grafico cdc\") {\n return \"grafico\";\n }\n\n if (raw === \"estrategia y creatividad\") {\n return \"estrategia\";\n }\n\n throw new Error(\n `Sección inválida en tarifario: \"${value}\". Usa solo \"Tarifario Gráfico CDC\" o \"Estrategia y Creatividad\".`\n );\n}\n\nfunction buildBaseId(row, section, category, service, workTypeId) {\n const rawCodigoBase = cleanText(row[\"Código base\"]);\n\n if (rawCodigoBase) {\n return slugify(rawCodigoBase);\n }\n\n return slugify(\n [section, category, service, workTypeId].filter(Boolean).join(\"_\")\n );\n}\n\nfunction getSourceRowNumber(row, index) {\n return (\n row.row_number ||\n row.rowNumber ||\n row.__rowNumber ||\n index + 2\n );\n}\n\nconst records = [];\nconst generatedCodeUpdates = [];\n\nitems.forEach((item, index) => {\n const row = item.json;\n\n const rawSection = cleanText(row[\"Sección\"]);\n const rawCategory = cleanText(row[\"Categoría\"]);\n const rawService = cleanText(row[\"Servicio / ítem\"]);\n const rawWorkType = cleanText(row[\"Tipo de trabajo\"] || \"Referencia\");\n const shortLabel = cleanText(row[\"Etiqueta corta\"] || \"Ref.\");\n const hourReference = cleanText(row[\"Hora hombre ref.\"]);\n const notes = cleanText(row[\"Observaciones / incluye\"]);\n\n // Ignora filas totalmente vacías.\n const hasAnyBusinessData = [\n rawSection,\n rawCategory,\n rawService,\n rawWorkType,\n row[\"Nivel 1 nombre\"],\n row[\"Nivel 1 monto/rango\"],\n row[\"Nivel 2 nombre\"],\n row[\"Nivel 2 monto/rango\"],\n row[\"Nivel 3 nombre\"],\n row[\"Nivel 3 monto/rango\"],\n ].some((value) => cleanText(value));\n\n if (!hasAnyBusinessData) {\n return;\n }\n\n const section = normalizeSection(rawSection);\n const category = rawCategory;\n const service = rawService;\n const workTypeLabel = rawWorkType;\n const workTypeId = slugify(workTypeLabel || \"reference\") || \"reference\";\n\n if (!category || !service) {\n throw new Error(\n `Fila ${getSourceRowNumber(row, index)} incompleta: Categoría y Servicio / ítem son obligatorios.`\n );\n }\n\n const catalogItemId = slugify(\n [section, category, service].filter(Boolean).join(\"_\")\n );\n\n const baseId = buildBaseId(row, section, category, service, workTypeId);\n\n if (!baseId) {\n throw new Error(`No se pudo generar Código base en la fila ${getSourceRowNumber(row, index)}.`);\n }\n\n const sourceRowNumber = getSourceRowNumber(row, index);\n\n if (!cleanText(row[\"Código base\"])) {\n generatedCodeUpdates.push({\n row_number: sourceRowNumber,\n codigo_base: baseId,\n });\n }\n\n const levels = [\n {\n number: 1,\n name: cleanText(row[\"Nivel 1 nombre\"]),\n amountRange: cleanText(row[\"Nivel 1 monto/rango\"]),\n note: cleanText(row[\"Nivel 1 nota\"]),\n },\n {\n number: 2,\n name: cleanText(row[\"Nivel 2 nombre\"]),\n amountRange: cleanText(row[\"Nivel 2 monto/rango\"]),\n note: cleanText(row[\"Nivel 2 nota\"]),\n },\n {\n number: 3,\n name: cleanText(row[\"Nivel 3 nombre\"]),\n amountRange: cleanText(row[\"Nivel 3 monto/rango\"]),\n note: cleanText(row[\"Nivel 3 nota\"]),\n },\n ];\n\n const validLevels = levels.filter((level) => level.name && level.amountRange);\n\n if (validLevels.length === 0) {\n throw new Error(\n `Fila ${sourceRowNumber}: agrega al menos un nivel con nombre y monto/rango.`\n );\n }\n\n for (const level of validLevels) {\n const levelLabel = level.name;\n const levelId = slugify(levelLabel || \"project\") || \"project\";\n const amount = parseAmountRange(level.amountRange);\n\n const id = slugify(\n [baseId, levelId].filter(Boolean).join(\"_\")\n );\n\n records.push({\n id,\n catalog_item_id: catalogItemId,\n section,\n category,\n service,\n notes,\n work_type_id: workTypeId,\n work_type_label: workTypeLabel,\n work_type_short_label: shortLabel,\n hour_reference: hourReference,\n level_id: levelId,\n level_label: levelLabel,\n reference: amount.reference,\n reference_min: amount.reference_min,\n reference_max: amount.reference_max,\n level_hint: level.note,\n sort_order: 10000 + index * 10 + level.number,\n is_active: true,\n });\n }\n});\n\nif (records.length === 0) {\n throw new Error(\"No se encontraron tarifas válidas. Se detiene para no afectar el catálogo.\");\n}\n\nconst seen = new Set();\n\nfor (const record of records) {\n if (seen.has(record.id)) {\n throw new Error(`Código único duplicado detectado en tarifario: ${record.id}`);\n }\n\n seen.add(record.id);\n\n if (\n record.reference_min !== null &&\n record.reference_max !== null &&\n record.reference_min > record.reference_max\n ) {\n throw new Error(`La tarifa ${record.id} tiene mínimo mayor que máximo.`);\n }\n}\n\nreturn [\n {\n json: {\n total_source_rows: items.length,\n total_records_to_sync: records.length,\n total_generated_codes: generatedCodeUpdates.length,\n records,\n generated_code_updates: generatedCodeUpdates,\n },\n },\n];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 416, 0 ], "id": "25030323-2bde-460c-b1ad-02e6260d67da", "name": "Code - Normalizar Tarifario" }, { "parameters": { "method": "POST", "url": "https://dbit.digitalcompass.agency/rest/v1/tablero_cdc_tariff_catalog?on_conflict=id", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "apikey", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Authorization", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q" }, { "name": "Content-Type", "value": "application/json" }, { "name": "Prefer", "value": "resolution=merge-duplicates,return=representation" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.records) }}", "options": {} }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 624, 0 ], "id": "a21e1648-fd8c-4cf3-acc3-50f91af1e4d9", "name": "Supabase - Upsert Tarifario" }, { "parameters": { "documentId": { "__rl": true, "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", "mode": "list", "cachedResultName": "Tarifario de Creatividad - Sep 2004", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" }, "sheetName": { "__rl": true, "value": 1041164995, "mode": "list", "cachedResultName": "Catálogo Tarifario App", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" }, "options": { "dataLocationOnSheet": { "values": { "rangeDefinition": "detectAutomatically" } } } }, "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 208, 0 ], "id": "e6d4f9ef-30da-4640-9002-96b43aa48729", "name": "Google Sheets - Leer Catálogo Tarifario App", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "jsCode": "const source = $('Code - Normalizar Tarifario').first().json;\n\nconst updates = source.generated_code_updates || [];\n\nreturn updates.map((update) => ({\n json: {\n row_number: update.row_number,\n codigo_base: update.codigo_base,\n },\n}));" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 832, 0 ], "id": "247f2f76-a1e4-4e51-b35f-a32bdaadf354", "name": "Code - Preparar códigos generados" }, { "parameters": { "operation": "update", "documentId": { "__rl": true, "value": "1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg", "mode": "list", "cachedResultName": "Tarifario de Creatividad - Sep 2004", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit?usp=drivesdk" }, "sheetName": { "__rl": true, "value": 1041164995, "mode": "list", "cachedResultName": "Catálogo Tarifario App", "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1BGTCXbeqo-QAaoBx2pHJo9p7Trzd7Qh0FpEFypSyTAg/edit#gid=1041164995" }, "columns": { "mappingMode": "defineBelow", "value": { "row_number": "={{ $json[\"row_number\"] }}", "Código base": "={{ $json[\"codigo_base\"] }}" }, "matchingColumns": [ "row_number" ], "schema": [ { "id": "Código base", "displayName": "Código base", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": false }, { "id": "Sección", "displayName": "Sección", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Categoría", "displayName": "Categoría", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Servicio / ítem", "displayName": "Servicio / ítem", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Tipo de trabajo", "displayName": "Tipo de trabajo", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Etiqueta corta", "displayName": "Etiqueta corta", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Hora hombre ref.", "displayName": "Hora hombre ref.", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 nombre", "displayName": "Nivel 1 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 monto/rango", "displayName": "Nivel 1 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 1 nota", "displayName": "Nivel 1 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 nombre", "displayName": "Nivel 2 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 monto/rango", "displayName": "Nivel 2 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 2 nota", "displayName": "Nivel 2 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 nombre", "displayName": "Nivel 3 nombre", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 monto/rango", "displayName": "Nivel 3 monto/rango", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Nivel 3 nota", "displayName": "Nivel 3 nota", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "Observaciones / incluye", "displayName": "Observaciones / incluye", "required": false, "defaultMatch": false, "display": true, "type": "string", "canBeUsedToMatch": true, "removed": true }, { "id": "row_number", "displayName": "row_number", "required": false, "defaultMatch": false, "display": true, "type": "number", "canBeUsedToMatch": true, "readOnly": true, "removed": false } ], "attemptToConvertTypes": false, "convertFieldsToString": false }, "options": {} }, "type": "n8n-nodes-base.googleSheets", "typeVersion": 4.7, "position": [ 1072, 0 ], "id": "60367c04-79f6-4c8a-99d1-bc9369c8ca61", "name": "Google Sheets - Escribir códigos únicos faltantes", "credentials": { "googleSheetsOAuth2Api": { "id": "K0hDZh3a85MpOHCs", "name": "Google Sheets account 2" } } }, { "parameters": { "rule": { "interval": [ { "field": "hours", "hoursInterval": 8 } ] } }, "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [ -16, 0 ], "id": "1a45e645-65af-4727-bf4f-15c7000ba2a2", "name": "Schedule Trigger" }, { "parameters": { "content": "## 💵 SYNC TARIFARIO CDC → SUPABASE\n\nEste flujo sincroniza automáticamente el tarifario editable desde Google Sheets hacia Supabase para que la app Tablero CDC use el catálogo actualizado.\n\nFrecuencia:\n- Cada 8 horas.\n\nProceso:\n1. Lee el Sheet “Catálogo Tarifario App”.\n2. Normaliza sección, categoría, servicio, tipo de trabajo y niveles.\n3. Convierte “monto/rango” en reference, reference_min y reference_max.\n4. Genera códigos únicos cuando faltan.\n5. Hace upsert en Supabase: tablero_cdc_tariff_catalog.\n6. Escribe de vuelta en el Sheet los códigos base generados.\n\nReglas importantes:\n- No sincroniza filas vacías.\n- Cada fila debe tener categoría, servicio y al menos un nivel con monto/rango.\n- Si hay códigos duplicados o secciones inválidas, el flujo se detiene para proteger el catálogo.\n- El Sheet es la fuente humana editable; Supabase es la fuente usada por la app.", "height": 640, "width": 1408, "color": 5 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -64, -432 ], "id": "23c63e0a-2e95-4ec5-b075-d08ae68754fc", "name": "Sticky Note" } ], "connections": { "Code - Normalizar Tarifario": { "main": [ [ { "node": "Supabase - Upsert Tarifario", "type": "main", "index": 0 } ] ] }, "Supabase - Upsert Tarifario": { "main": [ [ { "node": "Code - Preparar códigos generados", "type": "main", "index": 0 } ] ] }, "Google Sheets - Leer Catálogo Tarifario App": { "main": [ [ { "node": "Code - Normalizar Tarifario", "type": "main", "index": 0 } ] ] }, "Code - Preparar códigos generados": { "main": [ [ { "node": "Google Sheets - Escribir códigos únicos faltantes", "type": "main", "index": 0 } ] ] }, "Schedule Trigger": { "main": [ [ { "node": "Google Sheets - Leer Catálogo Tarifario App", "type": "main", "index": 0 } ] ] } }, "authors": "Isaac Aracena", "name": "Version 4ea3c2ee", "description": "", "autosaved": true, "workflowPublishHistory": [ { "createdAt": "2026-06-25T03:30:22.254Z", "id": 1861, "workflowId": "7eyfZEkGuDSUodDX", "versionId": "4ea3c2ee-0518-4a55-8c9a-ccffb26f471a", "event": "activated", "userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029" } ] } }