Files
Gesti-n-Reservas-Naturcalab…/apps/api/scripts/import-reservations.ts

255 lines
9.9 KiB
TypeScript

/**
* 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<Row>(wb.Sheets['LOS DRAGOS'], { header: 1, defval: null, raw: false });
const esquinita = XLSX.utils.sheet_to_json<Row>(wb.Sheets['LA ESQUINITA'], { header: 1, defval: null, raw: false });
const eventos = XLSX.utils.sheet_to_json<Row>(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);
});