255 lines
9.9 KiB
TypeScript
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);
|
|
});
|