{
  "name": "Partes de viajeros SES.HOSPEDAJES (muestra)",
  "nodes": [
    {
      "name": "Ejecutar a mano",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "parameters": {},
      "id": "116fff77-debd-5af1-9038-9bfcabb0bdd6"
    },
    {
      "name": "Configuración",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        300
      ],
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "bdbb9fc0-4b3a-5e14-bff3-d46a74528e81",
              "name": "codigoEstablecimiento",
              "type": "string",
              "value": "0000000000"
            },
            {
              "id": "cce04bfc-c026-5964-acac-485bb5db24c2",
              "name": "internet",
              "type": "boolean",
              "value": true
            },
            {
              "id": "1f9e8e50-25da-553e-856d-4214f0b6faaa",
              "name": "horaEntrada",
              "type": "string",
              "value": "16:00"
            },
            {
              "id": "be87c67c-b623-54be-99de-3601b2b0bd13",
              "name": "horaSalida",
              "type": "string",
              "value": "11:00"
            }
          ]
        },
        "options": {}
      },
      "id": "81c78380-4ce0-53ee-9e89-1d05173dda16"
    },
    {
      "name": "Huéspedes de ejemplo",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "jsCode": "// Datos ficticios. Cambia estos huéspedes para probar la validación.\nreturn [\n  {\n    \"json\": {\n      \"row_number\": 2,\n      \"Reserva\": \"EJEMPLO-01\",\n      \"Fecha contrato\": \"2026-08-02\",\n      \"Entrada\": \"2026-09-25 16:30\",\n      \"Salida\": \"2026-09-29\",\n      \"Habitaciones\": \"2\",\n      \"Tipo de pago\": \"Plataforma\",\n      \"Nombre\": \"Lena\",\n      \"Apellido 1\": \"Schmidt\",\n      \"Tipo documento\": \"Pasaporte\",\n      \"Número documento\": \"C01X00T47\",\n      \"Fecha nacimiento\": \"1986-04-12\",\n      \"Nacionalidad\": \"Alemania\",\n      \"Sexo\": \"Mujer\",\n      \"Dirección\": \"Lindenstraße 8\",\n      \"Municipio\": \"Köln\",\n      \"Código postal\": \"50674\",\n      \"País\": \"DEU\",\n      \"Correo\": \"lena@example.com\",\n      \"Parentesco\": \"PM\"\n    }\n  },\n  {\n    \"json\": {\n      \"row_number\": 3,\n      \"Reserva\": \"EJEMPLO-01\",\n      \"Entrada\": \"2026-09-25 16:30\",\n      \"Salida\": \"2026-09-29\",\n      \"Tipo de pago\": \"Plataforma\",\n      \"Nombre\": \"Mia\",\n      \"Apellido 1\": \"Schmidt\",\n      \"Fecha nacimiento\": \"2019-06-01\",\n      \"Nacionalidad\": \"DEU\",\n      \"Sexo\": \"M\",\n      \"Dirección\": \"Lindenstraße 8\",\n      \"Municipio\": \"Köln\",\n      \"Código postal\": \"50674\",\n      \"País\": \"DEU\",\n      \"Correo\": \"lena@example.com\",\n      \"Parentesco\": \"HJ\"\n    }\n  },\n  {\n    \"json\": {\n      \"row_number\": 4,\n      \"Reserva\": \"EJEMPLO-02\",\n      \"Entrada\": \"2026-09-25\",\n      \"Salida\": \"2026-09-29\",\n      \"Tipo de pago\": \"Tarjeta\",\n      \"Nombre\": \"Luis\",\n      \"Apellido 1\": \"Pérez\",\n      \"Tipo documento\": \"DNI\",\n      \"Número documento\": \"12345678A\",\n      \"Soporte documento\": \"XYZ000111\",\n      \"Fecha nacimiento\": \"1990-07-07\",\n      \"Nacionalidad\": \"ESP\",\n      \"Dirección\": \"Av. Primero de Mayo 3\",\n      \"Código municipio\": \"35017\",\n      \"Código postal\": \"35600\",\n      \"País\": \"ESP\",\n      \"Correo\": \"luis@example.com\"\n    }\n  }\n];"
      },
      "id": "203a1c09-12d9-5b6c-b6d5-632851211a97"
    },
    {
      "name": "Preparar partes",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        300
      ],
      "parameters": {
        "jsCode": "// Agrupa por reserva las filas de huéspedes pendientes cuya entrada ya ha\n// llegado, comprueba que estén todos los datos que exige Interior y genera el\n// XML de partes de viajeros (instrucciones de alta masiva v1.1.1 y servicio\n// web v3.1.2 de SES.HOSPEDAJES). Lo que no está completo no se envía: se\n// avisa al anfitrión de qué falta.\nconst cfg = $('Configuración').first().json;\nconst filas = $input.all().map((i) => i.json);\nconst ahora = new Date();\n\nconst txt = (v) => String(v ?? '').replace(/\\s+/g, ' ').trim();\nconst esc = (s) => txt(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\nconst sinTildes = (s) => txt(s).normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();\n\n// Fechas: admite 2026-09-25, 25/09/2026 y con hora («25/09/2026 16:30»).\nfunction fecha(v) {\n  const s = txt(v);\n  let m = s.match(/^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{1,2}):(\\d{2}))?/);\n  if (m) return { d: `${m[1]}-${m[2]}-${m[3]}`, h: m[4] ? `${m[4].padStart(2, '0')}:${m[5]}` : null };\n  m = s.match(/^(\\d{1,2})[/.-](\\d{1,2})[/.-](\\d{4})(?:\\s+(\\d{1,2}):(\\d{2}))?/);\n  if (m) return { d: `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`, h: m[4] ? `${m[4].padStart(2, '0')}:${m[5]}` : null };\n  return null;\n}\nconst conHora = (f, porDefecto) => `${f.d}T${f.h || porDefecto}:00`;\nconst edadEn = (nac, dia) => { const a = new Date(nac + 'T00:00:00'), b = new Date(dia + 'T00:00:00'); let e = b.getFullYear() - a.getFullYear(); if (b.getMonth() < a.getMonth() || (b.getMonth() === a.getMonth() && b.getDate() < a.getDate())) e--; return e; };\n\n// Códigos de las tablas oficiales, aceptando cómo lo escribiría una persona.\nconst DOCS = { nif: 'NIF', dni: 'NIF', nie: 'NIE', pas: 'PAS', pasaporte: 'PAS', passport: 'PAS', otro: 'OTRO' };\nconst SEXOS = { h: 'H', hombre: 'H', m: 'M', mujer: 'M', o: 'O', otro: 'O' };\nconst PAGOS = { efect: 'EFECT', efectivo: 'EFECT', tarjt: 'TARJT', tarjeta: 'TARJT', platf: 'PLATF', plataforma: 'PLATF', trans: 'TRANS', transferencia: 'TRANS', movil: 'MOVIL', treg: 'TREG', desti: 'DESTI', 'pago en destino': 'DESTI', otro: 'OTRO' };\nconst PARENTESCOS = ['AB', 'BA', 'BN', 'CD', 'CY', 'HJ', 'HR', 'NI', 'PM', 'SB', 'SG', 'TI', 'YN', 'TU', 'OT'];\nconst PAISES = { espana: 'ESP', spain: 'ESP', alemania: 'DEU', germany: 'DEU', deutschland: 'DEU', 'reino unido': 'GBR', 'united kingdom': 'GBR', uk: 'GBR', italia: 'ITA', italy: 'ITA', francia: 'FRA', france: 'FRA', 'paises bajos': 'NLD', holanda: 'NLD', netherlands: 'NLD', irlanda: 'IRL', ireland: 'IRL', belgica: 'BEL', belgium: 'BEL', portugal: 'PRT', suiza: 'CHE', switzerland: 'CHE', suecia: 'SWE', sweden: 'SWE', noruega: 'NOR', norway: 'NOR', dinamarca: 'DNK', denmark: 'DNK', polonia: 'POL', poland: 'POL', austria: 'AUT', 'estados unidos': 'USA', usa: 'USA' };\nconst pais = (v) => { const s = sinTildes(v); return PAISES[s] || (/^[a-z]{3}$/.test(s) ? s.toUpperCase() : null); };\n\n// Letra de control de DNI y NIE.\nconst LETRAS = 'TRWAGMYFPDXBNJZSQVHLCKE';\nfunction documentoValido(tipo, num) {\n  const n = num.toUpperCase().replace(/[\\s-]/g, '');\n  if (tipo === 'NIF') return /^\\d{8}[A-Z]$/.test(n) && LETRAS[Number(n.slice(0, 8)) % 23] === n[8];\n  if (tipo === 'NIE') { if (!/^[XYZ]\\d{7}[A-Z]$/.test(n)) return false; const d = Number('XYZ'.indexOf(n[0]) + n.slice(1, 8)); return LETRAS[d % 23] === n[8]; }\n  return n.length >= 3;\n}\n\nconst col = (f, ...nombres) => { for (const n of nombres) if (txt(f[n])) return txt(f[n]); return ''; };\nconst pendientes = filas.filter((f) => col(f, 'Reserva') && !col(f, 'Estado'));\nconst porReserva = new Map();\nfor (const f of pendientes) {\n  const r = col(f, 'Reserva');\n  if (!porReserva.has(r)) porReserva.set(r, []);\n  porReserva.get(r).push(f);\n}\n\nconst comunicaciones = [], errores = [], filasIncluidas = [];\nfor (const [reserva, personas] of porReserva) {\n  const p0 = personas[0];\n  const entrada = fecha(col(p0, 'Entrada'));\n  if (!entrada) { errores.push({ reserva, problema: 'Falta la fecha de entrada o no se entiende.' }); continue; }\n  // Solo cuando el huésped ya ha llegado (o llega hoy): el plazo son 24 horas desde la entrada.\n  const hoy = ahora.toLocaleDateString('sv-SE', { timeZone: 'Atlantic/Canary' });\n  if (entrada.d > hoy) continue;\n  const salida = fecha(col(p0, 'Salida'));\n  const contrato = fecha(col(p0, 'Fecha contrato')) || entrada;\n  const tipoPago = PAGOS[sinTildes(col(p0, 'Tipo de pago'))];\n  const faltan = [];\n  if (!salida) faltan.push('fecha de salida');\n  if (!tipoPago) faltan.push('tipo de pago (efectivo, tarjeta, plataforma, transferencia…)');\n\n  const bloques = [];\n  const hayMenor = personas.some((p) => { const n = fecha(col(p, 'Fecha nacimiento')); return n && edadEn(n.d, entrada.d) < 18; });\n  let adultoConParentesco = false;\n  for (const p of personas) {\n    const quien = `${col(p, 'Nombre')} ${col(p, 'Apellido 1')}`.trim() || `fila ${p.row_number || '?'}`;\n    const nac = fecha(col(p, 'Fecha nacimiento'));\n    const menor = nac ? edadEn(nac.d, entrada.d) < 18 : false;\n    const tipoDoc = DOCS[sinTildes(col(p, 'Tipo documento'))];\n    const numDoc = col(p, 'Número documento', 'Numero documento').toUpperCase().replace(/[\\s-]/g, '');\n    const paisDom = pais(col(p, 'País', 'Pais'));\n    const nacionalidad = pais(col(p, 'Nacionalidad'));\n    const sexo = SEXOS[sinTildes(col(p, 'Sexo'))];\n    const parentesco = col(p, 'Parentesco').toUpperCase();\n    const f = [];\n    if (!col(p, 'Nombre')) f.push('nombre');\n    if (!col(p, 'Apellido 1')) f.push('primer apellido');\n    if (!nac) f.push('fecha de nacimiento');\n    if (!menor) {\n      if (!tipoDoc) f.push('tipo de documento (DNI, NIE, pasaporte)');\n      if (!numDoc) f.push('número de documento');\n      else if (tipoDoc && !documentoValido(tipoDoc, numDoc)) f.push(`número de ${tipoDoc} válido`);\n    }\n    if (['NIF', 'NIE'].includes(tipoDoc)) {\n      if (!col(p, 'Apellido 2')) f.push('segundo apellido (obligatorio con DNI o NIE)');\n      if (!col(p, 'Soporte documento')) f.push('número de soporte del documento');\n    }\n    if (!col(p, 'Dirección', 'Direccion')) f.push('dirección');\n    if (!col(p, 'Código postal', 'Codigo postal')) f.push('código postal');\n    if (!paisDom) f.push('país de residencia (código de tres letras, como ESP o DEU)');\n    if (paisDom === 'ESP' && !/^\\d{5}$/.test(col(p, 'Código municipio', 'Codigo municipio'))) f.push('código INE del municipio (5 cifras)');\n    if (paisDom && paisDom !== 'ESP' && !col(p, 'Municipio')) f.push('ciudad de residencia');\n    if (!col(p, 'Teléfono', 'Telefono') && !col(p, 'Correo')) f.push('teléfono o correo');\n    if (menor && !PARENTESCOS.includes(parentesco)) f.push('parentesco (el menor con el adulto: HJ hijo/a, NI nieto/a…)');\n    if (!menor && parentesco && PARENTESCOS.includes(parentesco)) adultoConParentesco = true;\n    if (f.length) { faltan.push(`${quien}: ${f.join(', ')}`); continue; }\n\n    const nodos = [\n      ['rol', 'VI'], ['nombre', col(p, 'Nombre')], ['apellido1', col(p, 'Apellido 1')], ['apellido2', col(p, 'Apellido 2')],\n      ['tipoDocumento', tipoDoc || ''], ['numeroDocumento', numDoc], ['soporteDocumento', col(p, 'Soporte documento').toUpperCase()],\n      ['fechaNacimiento', nac.d], ['nacionalidad', nacionalidad || ''], ['sexo', sexo || ''],\n    ].filter(([, v]) => v).map(([k, v]) => `<${k}>${esc(v)}</${k}>`).join('');\n    const dir = [\n      ['direccion', col(p, 'Dirección', 'Direccion')], ['direccionComplementaria', col(p, 'Dirección complementaria')],\n      ['codigoMunicipio', paisDom === 'ESP' ? col(p, 'Código municipio', 'Codigo municipio') : ''], ['nombreMunicipio', paisDom !== 'ESP' ? col(p, 'Municipio') : ''],\n      ['codigoPostal', col(p, 'Código postal', 'Codigo postal')], ['pais', paisDom],\n    ].filter(([, v]) => v).map(([k, v]) => `<${k}>${esc(v)}</${k}>`).join('');\n    const contacto = [['telefono', col(p, 'Teléfono', 'Telefono')], ['correo', col(p, 'Correo')], ['parentesco', PARENTESCOS.includes(parentesco) ? parentesco : '']]\n      .filter(([, v]) => v).map(([k, v]) => `<${k}>${esc(v)}</${k}>`).join('');\n    bloques.push(`<persona>${nodos}<direccion>${dir}</direccion>${contacto}</persona>`);\n  }\n  if (hayMenor && !adultoConParentesco) faltan.push('hay un menor: indica también en «Parentesco» la relación de un adulto con él (PM padre o madre, AB abuelo/a, TU tutor/a…)');\n  if (faltan.length) { errores.push({ reserva, problema: faltan.join(' · ') }); continue; }\n\n  const habit = Number(col(p0, 'Habitaciones')) || null;\n  comunicaciones.push(`<comunicacion><contrato><referencia>${esc(reserva.slice(0, 50))}</referencia><fechaContrato>${contrato.d}</fechaContrato>`\n    + `<fechaEntrada>${conHora(entrada, cfg.horaEntrada || '16:00')}</fechaEntrada><fechaSalida>${conHora(salida, cfg.horaSalida || '11:00')}</fechaSalida><numPersonas>${personas.length}</numPersonas>`\n    + (habit ? `<numHabitaciones>${habit}</numHabitaciones>` : '') + `<internet>${cfg.internet === true || cfg.internet === 'true'}</internet>`\n    + `<pago><tipoPago>${tipoPago}</tipoPago></pago></contrato>${bloques.join('')}</comunicacion>`);\n  filasIncluidas.push(...personas.map((p) => ({ row_number: p.row_number, reserva })));\n}\n\nif (!comunicaciones.length && !errores.length) return [];\nconst xml = comunicaciones.length\n  ? `<?xml version=\"1.0\" encoding=\"UTF-8\"?><alt:peticion xmlns:alt=\"http://www.neg.hospedajes.mir.es/altaParteHospedaje\"><solicitud><codigoEstablecimiento>${esc(cfg.codigoEstablecimiento)}</codigoEstablecimiento>${comunicaciones.join('')}</solicitud></alt:peticion>`\n  : null;\nreturn [{ json: { xml, reservas: [...new Set(filasIncluidas.map((f) => f.reserva))], filas: filasIncluidas, errores } }];\n"
      },
      "id": "72df9d78-5f28-5e40-8c9e-e77e074b9d7f"
    },
    {
      "name": "Ver resultado",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "parameters": {
        "jsCode": "// Enseña el resultado: el XML de las reservas completas y qué falta en las demás.\n// En la versión completa esto se envía a Interior (o te llega por correo) y se marca en la hoja.\nconst r = $input.first().json;\nreturn [{ json: { reservasListas: r.reservas, faltan: r.errores, xml: r.xml } }];\n"
      },
      "id": "d193644c-0780-5976-b17d-06d250234878"
    },
    {
      "name": "Versión de muestra",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -200
      ],
      "parameters": {
        "width": 640,
        "height": 360,
        "color": 5,
        "content": "## Partes de viajeros para SES.HOSPEDAJES · versión de muestra\nPulsa «Test workflow»: valida tres huéspedes ficticios con las reglas del Ministerio del Interior (letra del DNI, segundo apellido y soporte, código INE, parentesco con menores…) y genera el XML oficial de partes de viajeros. Una de las reservas tiene errores a propósito.\n\n**No incluye**: leer los huéspedes de tu hoja, la ejecución automática cada hora, el envío al servicio web de Interior ni el fichero por correo.\n\n**Versión completa, instalada y mantenida:** https://www.albydev.com/n8n-partes-viajeros.html"
      },
      "id": "e3a6ec8d-1cf6-5ede-beb3-cd7549510d5c"
    }
  ],
  "connections": {
    "Ejecutar a mano": {
      "main": [
        [
          {
            "node": "Configuración",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuración": {
      "main": [
        [
          {
            "node": "Huéspedes de ejemplo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Huéspedes de ejemplo": {
      "main": [
        [
          {
            "node": "Preparar partes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preparar partes": {
      "main": [
        [
          {
            "node": "Ver resultado",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Atlantic/Canary",
    "saveDataSuccessExecution": "none",
    "saveManualExecutions": false
  },
  "pinData": {},
  "active": false,
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}