{
  "name": "Logixb2b — Asignación de leads por territorio",
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Webhook lead → Config territorio → Code (match CP/provincia + round-robin) → Set Payload CRM → Slack → Respuesta webhook con la asignación."
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Madrid"
  },
  "active": false,
  "pinData": {},
  "tags": [
    {
      "name": "Logixb2b"
    },
    {
      "name": "recursos"
    },
    {
      "name": "crm"
    },
    {
      "name": "territorio"
    }
  ],
  "nodes": [
    {
      "id": "sticky-instrucciones-logix",
      "name": "INSTRUCCIONES (conectar + VPS)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -480,
        40
      ],
      "parameters": {
        "content": "## INSTRUCCIONES — Asignación de leads por territorio\n\n**Recurso:** `/recursos/asignacion-leads-territorio-crm`\n**Guía:** /automatizar-asignacion-leads-territorio-crm\n**Zona:** Europe/Madrid · `active: false` al importar (tú lo activas).\n\n### 1) Importar\n1. n8n → ⋯ → **Import from File** → este JSON.\n2. El flujo llega **inactive**. No pegues secretos en el lienzo ni en el archivo.\n\n### 2) Credenciales y configuración por nodo\n- **Webhook Lead** (`webhook`): sin credencial. Tras Active, copia **URL de producción** (HTTPS del VPS/Cloud). El emisor (formulario, CRM, bot) debe POST a esa URL.\n- **Config territorio** (`Set` Config): **no lleva credencial**. Aquí van URLs/placeholders (`REEMPLAZA_*`, `example.com`, `TU-CRM`). Sustituye todos antes de Active.\n- **Asignar territorio** (`Code`): sin credencial. Revisa límites/umbrales en el JS; no hardcodees secretos.\n- **Payload CRM (a completar)** (`Set`): sin credencial. Revisa expresiones `={{ }}` y campos de salida.\n- **Aviso Slack** (`HTTP Request` → Slack/Teams): crea Incoming Webhook en Slack (o Adaptive Card URL en Teams). Pega la URL en **Config** o en este nodo. Auth: none (la URL ya es secreta).\n- **Responder asignación** (`respondToWebhook`): sin credencial. Tras Active, copia **URL de producción** (HTTPS del VPS/Cloud). El emisor (formulario, CRM, bot) debe POST a esa URL.\n\n### 3) Orden recomendado de puesta en marcha\n1. Rellena el nodo **Config** (URLs reales, umbrales, webhooks de Slack).\n2. Crea Credentials en n8n (candado de cada nodo HTTP / Sheets / OpenAI) y **enlázalas**.\n3. Ejecuta **una vez** (Manual / Test URL del Webhook / \"Execute workflow\").\n4. Verifica CRM + Slack/Sheets (y que la IA no escriba en producción sin HITL).\n5. Solo entonces: interruptor **Active**.\n\n### 4) Producción 24/7 en VPS (UE)\nImportar en localhost no basta: webhooks y cron necesitan n8n encendido.\n\n1. VPS en UE (Hetzner/OVH/IONOS…) + Docker Compose + **Postgres** + HTTPS (Caddy/Nginx).\n2. Variables mínimas: `GENERIC_TIMEZONE=Europe/Madrid`, `WEBHOOK_URL=https://n8n.tudominio.com/`, `N8N_ENCRYPTION_KEY`, `N8N_HOST` / `N8N_PROTOCOL=https`.\n3. Firewall: solo 80/443 (+ SSH). Backups del volumen Postgres + export de workflows.\n4. Importa este JSON en la instancia de producción, vuelve a crear Credentials (no se copian solas), actualiza URLs del Config al dominio real.\n5. Pon **Active**. Prueba el Webhook de producción o espera el próximo cron.\n6. Alternativa: **n8n Cloud** (mismo JSON; Credentials en la UI Cloud).\n\n### 5) RGPD + EU AI Act (resumen)\nSi hay nodos IA sois **deployers** (Reglamento UE 2024/1689). Art. 4 alfabetización. **HITL** antes de outreach, scores masivos o pausas de cuenta. Minimiza PII en Slack/logs. Informativo — no es asesoramiento legal. /uso-responsable-de-ia-y-transparencia\n",
        "height": 1500,
        "width": 580,
        "color": 4
      }
    },
    {
      "id": "terr-webhook",
      "name": "Webhook Lead",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "logix-asignar-territorio-lead",
      "parameters": {
        "httpMethod": "POST",
        "path": "asignar-territorio-lead",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "terr-config",
      "name": "Config territorio",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        300,
        300
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "terr-c1",
              "name": "slackWebhookUrl",
              "value": "https://example.com/aviso-equipo-slack",
              "type": "string"
            },
            {
              "id": "terr-c2",
              "name": "defaultOwnerEmail",
              "value": "ventas@tuempresa.example",
              "type": "string"
            },
            {
              "id": "terr-c3",
              "name": "crmMode",
              "value": "generic",
              "type": "string"
            },
            {
              "id": "terr-c4",
              "name": "territoryMap",
              "value": "{\"28\":[\"ana@tuempresa.example\",\"luis@tuempresa.example\"],\"08\":[\"marta@tuempresa.example\"],\"41\":[\"carlos@tuempresa.example\"],\"Madrid\":[\"ana@tuempresa.example\",\"luis@tuempresa.example\"],\"Barcelona\":[\"marta@tuempresa.example\"],\"Sevilla\":[\"carlos@tuempresa.example\"],\"_default\":[\"ventas@tuempresa.example\"]}",
              "type": "string"
            },
            {
              "id": "terr-c5",
              "name": "rawBody",
              "value": "={{ $json.body || $json }}",
              "type": "object"
            },
            {
              "id": "terr-c6",
              "name": "receivedAt",
              "value": "={{ $now.setZone('Europe/Madrid').toFormat('yyyy-MM-dd HH:mm') }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "terr-assign",
      "name": "Asignar territorio",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        300
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "const cfg = item.json;\nconst raw = cfg.rawBody || {};\nconst norm = (v) => String(v == null ? '' : v).trim();\n\nconst cp = norm(raw.cp || raw.postalCode || raw.codigoPostal || raw.zip);\nconst provincia = norm(raw.provincia || raw.state || raw.region || raw.province);\nconst leadEmail = norm(raw.email || raw.contactEmail || raw.leadEmail).toLowerCase();\nconst leadName = norm(raw.name || raw.nombre || raw.contactName);\nconst leadCompany = norm(raw.company || raw.empresa);\nconst cpPrefix = cp.slice(0, 2);\n\nlet territoryMap = {};\ntry {\n  territoryMap = JSON.parse(cfg.territoryMap || '{}');\n} catch (e) {\n  territoryMap = {};\n}\n\nfunction toList(v) {\n  if (Array.isArray(v)) return v.filter(Boolean);\n  if (v) return [v];\n  return [];\n}\n\nfunction hashString(str) {\n  let h = 0;\n  for (let i = 0; i < str.length; i++) {\n    h = (h * 31 + str.charCodeAt(i)) >>> 0;\n  }\n  return h;\n}\n\nlet matchedBy = 'default';\nlet territoryKey = '_default';\nlet owners = toList(territoryMap._default);\n\nif (cpPrefix && territoryMap[cpPrefix]) {\n  matchedBy = 'cp';\n  territoryKey = cpPrefix;\n  owners = toList(territoryMap[cpPrefix]);\n} else if (provincia) {\n  const provinciaKey = Object.keys(territoryMap).find(\n    (k) => k.toLowerCase() === provincia.toLowerCase()\n  );\n  if (provinciaKey) {\n    matchedBy = 'provincia';\n    territoryKey = provinciaKey;\n    owners = toList(territoryMap[provinciaKey]);\n  }\n}\n\nlet ownerEmail = '';\nlet reason = '';\n\nif (owners.length === 1) {\n  ownerEmail = owners[0];\n  reason = `Territorio '${territoryKey}' con un unico responsable asignado.`;\n} else if (owners.length > 1) {\n  const seed = leadEmail || (leadCompany + cp) || String(Date.now());\n  const idx = hashString(seed + territoryKey) % owners.length;\n  ownerEmail = owners[idx];\n  reason = `Territorio '${territoryKey}' con ${owners.length} responsables, reparto round-robin por hash (posicion ${idx + 1}/${owners.length}).`;\n  matchedBy = matchedBy + '+round-robin';\n} else {\n  ownerEmail = cfg.defaultOwnerEmail;\n  matchedBy = 'default';\n  territoryKey = '_default';\n  reason = 'Sin match en territoryMap para el CP/provincia recibido, se usa defaultOwnerEmail.';\n}\n\nif (!ownerEmail) {\n  ownerEmail = cfg.defaultOwnerEmail;\n  reason = 'territoryMap sin responsables validos para esta zona, se usa defaultOwnerEmail.';\n}\n\nreturn {\n  json: {\n    ...cfg,\n    cp,\n    provincia,\n    cpPrefix,\n    leadEmail,\n    leadName,\n    leadCompany,\n    territoryKey,\n    matchedBy,\n    ownerEmail,\n    territoryLabel: territoryKey === '_default' ? 'Sin territorio (default)' : territoryKey,\n    reason\n  }\n};"
      }
    },
    {
      "id": "terr-crm-payload",
      "name": "Payload CRM (a completar)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        900,
        300
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "terr-p1",
              "name": "ownerEmail",
              "value": "={{ $('Asignar territorio').item.json.ownerEmail }}",
              "type": "string"
            },
            {
              "id": "terr-p2",
              "name": "territoryLabel",
              "value": "={{ $('Asignar territorio').item.json.territoryLabel }}",
              "type": "string"
            },
            {
              "id": "terr-p3",
              "name": "reason",
              "value": "={{ $('Asignar territorio').item.json.reason }}",
              "type": "string"
            },
            {
              "id": "terr-p4",
              "name": "crmMode",
              "value": "={{ $('Asignar territorio').item.json.crmMode }}",
              "type": "string"
            },
            {
              "id": "terr-p5",
              "name": "crmPayload",
              "value": "={{ $('Asignar territorio').item.json.crmMode === 'hubspot' ? JSON.stringify({ properties: { hubspot_owner_email: $('Asignar territorio').item.json.ownerEmail } }) : $('Asignar territorio').item.json.crmMode === 'pipedrive' ? JSON.stringify({ owner_id: $('Asignar territorio').item.json.ownerEmail }) : JSON.stringify({ ownerEmail: $('Asignar territorio').item.json.ownerEmail, territory: $('Asignar territorio').item.json.territoryLabel }) }}",
              "type": "string"
            },
            {
              "id": "terr-p6",
              "name": "leadEmail",
              "value": "={{ $('Asignar territorio').item.json.leadEmail }}",
              "type": "string"
            },
            {
              "id": "terr-p7",
              "name": "leadName",
              "value": "={{ $('Asignar territorio').item.json.leadName }}",
              "type": "string"
            },
            {
              "id": "terr-p8",
              "name": "leadCompany",
              "value": "={{ $('Asignar territorio').item.json.leadCompany }}",
              "type": "string"
            },
            {
              "id": "terr-p9",
              "name": "slackWebhookUrl",
              "value": "={{ $('Asignar territorio').item.json.slackWebhookUrl }}",
              "type": "string"
            },
            {
              "id": "terr-p10",
              "name": "receivedAt",
              "value": "={{ $('Asignar territorio').item.json.receivedAt }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "terr-slack",
      "name": "Aviso Slack",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        300
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $('Payload CRM (a completar)').item.json.slackWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: 'Lead asignado por territorio\\nOwner: ' + $('Payload CRM (a completar)').item.json.ownerEmail + ' (' + $('Payload CRM (a completar)').item.json.territoryLabel + ')\\n' + $('Payload CRM (a completar)').item.json.reason + '\\nLead: ' + ($('Payload CRM (a completar)').item.json.leadName || 'sin nombre') + ' · ' + ($('Payload CRM (a completar)').item.json.leadCompany || 'sin empresa') + ' · ' + ($('Payload CRM (a completar)').item.json.leadEmail || 'sin email') + '\\n' + $('Payload CRM (a completar)').item.json.receivedAt }) }}",
        "options": {
          "timeout": 20000
        }
      }
    },
    {
      "id": "terr-respond",
      "name": "Responder asignación",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1500,
        300
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, ownerEmail: $('Payload CRM (a completar)').item.json.ownerEmail, territoryLabel: $('Payload CRM (a completar)').item.json.territoryLabel, reason: $('Payload CRM (a completar)').item.json.reason, crmMode: $('Payload CRM (a completar)').item.json.crmMode, crmPayload: $('Payload CRM (a completar)').item.json.crmPayload, receivedAt: $('Payload CRM (a completar)').item.json.receivedAt }) }}",
        "options": {}
      }
    }
  ],
  "connections": {
    "Webhook Lead": {
      "main": [
        [
          {
            "node": "Config territorio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config territorio": {
      "main": [
        [
          {
            "node": "Asignar territorio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Asignar territorio": {
      "main": [
        [
          {
            "node": "Payload CRM (a completar)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Payload CRM (a completar)": {
      "main": [
        [
          {
            "node": "Aviso Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aviso Slack": {
      "main": [
        [
          {
            "node": "Responder asignación",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
