/** * Importa reservas reales desde el Excel ALQUILER_CASAS_2026_LIMPIO.xlsx. * * - Borra TODAS las reservas existentes (cascade limpia notification_events y contracts) * - Inserta filas de LOS DRAGOS y LA ESQUINITA como estancias (origin Teneriffa2000) * - Inserta filas de EVENTOS como reservas tipo evento (1 noche, is_event=true) * - Encola notificaciones futuras (24h, 10d, 48h) usando la misma lógica que runner.ts * - NO envía emails CRUD (escribe directo a Supabase, sin pasar por /api/notifications) * * Uso: * pnpm --filter @naturcalabacera/api exec tsx --env-file=.env scripts/import-reservations.ts # dry-run * pnpm --filter @naturcalabacera/api exec tsx --env-file=.env scripts/import-reservations.ts --apply # ejecuta */ // Override de DNS: traefik.me wildcards no resuelven con DNS público en algunas redes. // Forzamos el host de Supabase a su IP conocida. import dns from 'dns'; const SUPABASE_HOST_IP = '72.62.155.93'; const originalLookup = dns.lookup.bind(dns); // @ts-expect-error monkey-patch dns.lookup = (hostname: string, options: unknown, callback: unknown) => { if (typeof hostname === 'string' && hostname.endsWith('.traefik.me')) { const opts = typeof options === 'object' && options !== null ? (options as { all?: boolean }) : {}; const cb = (typeof options === 'function' ? options : callback) as (err: NodeJS.ErrnoException | null, addressOrList: unknown, family?: number) => void; if (opts.all) return cb(null, [{ address: SUPABASE_HOST_IP, family: 4 }]); return cb(null, SUPABASE_HOST_IP, 4); } // @ts-expect-error fallback return originalLookup(hostname, options, callback); }; // Stubs para env vars no necesarias en este script (email/notif destinos). // Se setean ANTES de importar módulos que las requieren. for (const k of [ 'N8N_EMAIL_WEBHOOK_URL', 'NOTIFICATION_EMAIL_TENERIFFA', 'NOTIFICATION_EMAIL_NATUR', 'NOTIFICATION_EMAIL_POOL_HEATING', ]) { if (!process.env[k]) process.env[k] = 'unused@import.local'; } import { readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import * as XLSX from 'xlsx'; import type { NewReservation, Property, ReservationOrigin } from '@naturcalabacera/shared'; // Imports dinámicos (después de stubear env) const { supabaseAdmin } = await import('../src/lib/supabase.js'); const { scheduleNotificationsForReservation } = await import('../src/jobs/runner.js'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, '../../..'); const EXCEL_PATH = path.join(REPO_ROOT, 'ALQUILER_CASAS_2026_LIMPIO.xlsx'); const APPLY = process.argv.includes('--apply'); type Row = (string | null)[]; function parseDateDMY(s: string | null): string | null { if (!s) return null; const m = s.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (!m) return null; const [, d, mo, y] = m; return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; } function num(s: string | null): number { if (!s) return 0; const n = parseInt(String(s).replace(/[^0-9-]/g, ''), 10); return Number.isFinite(n) ? n : 0; } function addDays(iso: string, days: number): string { const d = new Date(iso + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } function detectEventType(evento: string): { event_type: string; event_type_other?: string } { const t = evento.toLowerCase(); if (t.includes('boda')) return { event_type: 'Boda' }; if (t.includes('comuni')) return { event_type: 'Comunión' }; if (t.includes('cumple')) return { event_type: 'Cumpleaños' }; if (t.includes('bautizo')) return { event_type: 'Otro', event_type_other: 'Bautizo' }; return { event_type: 'Otro', event_type_other: evento }; } function mapStayRow(row: Row, property: Property): NewReservation | null { const [invoice, status, entrada, salida, huesped, adultos, ninos, , , , limpieza, , , , notas] = row; if (!entrada || !salida || !huesped) return null; const start = parseDateDMY(entrada); const end = parseDateDMY(salida); if (!start || !end) return null; if (status && String(status).toUpperCase().includes('CANCEL')) return null; const notasStr = (notas ?? '').toString(); const hasPoolHeating = /calefacci/i.test(notasStr); const hasCleaning = !!limpieza && num(limpieza) > 0; return { start_date: start, end_date: end, client_name: String(huesped).trim(), origin: 'Teneriffa2000' as ReservationOrigin, property, invoice_number: invoice ? String(invoice).trim() : undefined, adults_count: num(adultos), children_count: num(ninos), has_cleaning: hasCleaning, has_pool_heating: hasPoolHeating, has_flies_products: false, observations: notasStr.trim() || undefined, }; } function mapEventRow(row: Row): NewReservation | null { const [fecha, , casa, evento, pax, organizador, estado, notas] = row; if (!fecha || !casa || !evento) return null; const start = parseDateDMY(fecha); if (!start) return null; if (estado && /cancel/i.test(String(estado))) return null; const property: Property | null = /esquinita/i.test(String(casa)) ? 'la_esquinita' : /dragos/i.test(String(casa)) ? 'los_dragos' : null; if (!property) return null; const { event_type, event_type_other } = detectEventType(String(evento)); const obsParts: string[] = [String(evento).trim()]; if (organizador) obsParts.push(`Organizador: ${organizador}`); if (notas) obsParts.push(String(notas)); return { start_date: start, end_date: addDays(start, 1), client_name: String(evento).trim(), origin: 'Naturcalabacera' as ReservationOrigin, property, adults_count: 0, children_count: 0, has_cleaning: false, has_pool_heating: false, has_flies_products: false, is_event: true, event_type, event_type_other, attendees_count: pax ? num(pax) : undefined, observations: obsParts.join(' · '), }; } function isHeaderOrTotalRow(row: Row): boolean { if (!row || row.every((c) => c === null || c === '')) return true; const first = (row[0] ?? '').toString().trim().toUpperCase(); if (first === 'TOTAL' || first === 'Nº FACTURA' || first.startsWith('LOS DRAGOS') || first.startsWith('LA ESQUINITA')) return true; if (first.startsWith('RESERVAS CON NOCHES')) return true; return false; } async function main() { console.log(`[import] Modo: ${APPLY ? 'APPLY (escribe en BD)' : 'DRY-RUN (no escribe)'}`); console.log(`[import] Excel: ${EXCEL_PATH}`); const buf = readFileSync(EXCEL_PATH); const wb = XLSX.read(buf, { type: 'buffer' }); const dragos = XLSX.utils.sheet_to_json(wb.Sheets['LOS DRAGOS'], { header: 1, defval: null, raw: false }); const esquinita = XLSX.utils.sheet_to_json(wb.Sheets['LA ESQUINITA'], { header: 1, defval: null, raw: false }); const eventos = XLSX.utils.sheet_to_json(wb.Sheets['EVENTOS'], { header: 1, defval: null, raw: false }); const reservations: NewReservation[] = []; for (const r of dragos) { if (isHeaderOrTotalRow(r)) continue; const m = mapStayRow(r, 'los_dragos'); if (m) reservations.push(m); } for (const r of esquinita) { if (isHeaderOrTotalRow(r)) continue; const m = mapStayRow(r, 'la_esquinita'); if (m) reservations.push(m); } for (const r of eventos) { if (isHeaderOrTotalRow(r)) continue; const first = (r[0] ?? '').toString().trim().toUpperCase(); if (first === 'FECHA' || first.startsWith('BODAS') || first.startsWith('EVENTOS PUNTUALES')) continue; const m = mapEventRow(r); if (m) reservations.push(m); } console.log(`[import] Reservas a crear: ${reservations.length}`); console.log(`[import] - Estancias Los Dragos: ${reservations.filter(r => r.property === 'los_dragos' && !r.is_event).length}`); console.log(`[import] - Estancias La Esquinita: ${reservations.filter(r => r.property === 'la_esquinita' && !r.is_event).length}`); console.log(`[import] - Eventos: ${reservations.filter(r => r.is_event).length}`); console.log('\n[import] Muestra de las primeras 3 filas mapeadas:'); console.log(JSON.stringify(reservations.slice(0, 3), null, 2)); if (!APPLY) { console.log('\n[import] Dry-run. Re-ejecuta con --apply para escribir en BD.'); return; } console.log('\n[import] Borrando todas las reservas existentes...'); const { count: existingCount } = await supabaseAdmin .from('reservations') .select('*', { count: 'exact', head: true }); console.log(`[import] Reservas existentes: ${existingCount ?? '?'}`); // Borrado total. El cascade limpia notification_events y contracts. const { error: delErr } = await supabaseAdmin .from('reservations') .delete() .not('id', 'is', null); // condición trivial requerida por supabase-js if (delErr) { console.error('[import] Error al borrar:', delErr.message); process.exit(1); } console.log('[import] ✓ Borrado completado.'); console.log('\n[import] Insertando reservas...'); const { data: inserted, error: insErr } = await supabaseAdmin .from('reservations') .insert(reservations) .select('*'); if (insErr) { console.error('[import] Error al insertar:', insErr.message); process.exit(1); } console.log(`[import] ✓ Insertadas ${inserted?.length ?? 0} reservas.`); console.log('\n[import] Encolando notificaciones futuras (sin envío inmediato)...'); let scheduled = 0; for (const res of inserted ?? []) { await scheduleNotificationsForReservation(res as never, 'created'); scheduled++; } console.log(`[import] ✓ Procesadas ${scheduled} reservas para scheduling.`); const { count: pending } = await supabaseAdmin .from('notification_events') .select('*', { count: 'exact', head: true }) .eq('status', 'pending'); console.log(`[import] notification_events pendientes: ${pending ?? 0}`); console.log('\n[import] ✅ Importación completada.'); } main().catch((err) => { console.error('[import] Error fatal:', err); process.exit(1); });