#!/usr/bin/env node
// src/cli/sync.ts - CLI para ejecutar sincronizaciones
// Uso: npx tsx src/cli/sync.ts [--once | --cron | --help]
import { config as dotenvConfig } from 'dotenv';
import cron from 'node-cron';
import { sheetsClient } from '../sheets-client.ts';
import { dbClient } from '../db-client.ts';
import { loadConfig, loadGoogleCredentials } from '../services/config.ts';
import { logger } from '../services/logger.ts';
import type { SyncResult, ColumnMapping, ReservaRecord } from '../types.ts';
dotenvConfig();
// ═══════════════════════════════════════════════════════════════════════════
// SYNC SERVICE (Integrado del proyecto original)
// ═══════════════════════════════════════════════════════════════════════════
class SyncCLI {
private config = loadConfig();
// Calcula el total pagado desde deposit + invoiceitems
private calcularTotalPagado(record: ReservaRecord): number {
let total = 0;
const deposit = parseFloat(String(record.deposit || '0')) || 0;
if (deposit > 0) total += deposit;
try {
const invoiceItems = (record as any).invoiceitems;
if (invoiceItems && typeof invoiceItems === 'string') {
const items = JSON.parse(invoiceItems);
if (Array.isArray(items)) {
const pagos = items.filter((item: any) =>
item.type === 'payment' && item.amount && parseFloat(item.amount) > 0
);
for (const pago of pagos) {
total += parseFloat(pago.amount) || 0;
}
}
}
} catch (e) {
// JSON invalido, ignorar
}
return total;
}
// Transforma un registro de DB al formato de Sheet
private transformDbToSheet(record: ReservaRecord, mapping: ColumnMapping[]): Record<string, any> {
const result: Record<string, any> = {};
for (const col of mapping) {
if (col.dbColumn === '_totalPagado') {
result[col.sheetColumn] = this.calcularTotalPagado(record);
continue;
}
const value = (record as any)[col.dbColumn];
result[col.sheetColumn] = col.transform
? col.transform(value)
: (value ?? '');
}
return result;
}
// Obtiene los headers para la hoja
private getSheetHeaders(): string[] {
return this.config.columnMapping.map(col => col.sheetColumn);
}
// Sincroniza reservas futuras a Google Sheets
async syncReservasFuturas(): Promise<SyncResult> {
const startTime = new Date();
const result: SyncResult = {
success: false,
startTime,
endTime: new Date(),
duration: 0,
rowsProcessed: 0,
rowsAdded: 0,
rowsUpdated: 0,
rowsDeleted: 0,
errors: []
};
logger.syncStart('Reservas Futuras');
try {
// Obtener reservas futuras
const reservas = await dbClient.getReservasFuturas();
result.rowsProcessed = reservas.length;
logger.info(`Obtenidas ${reservas.length} reservas futuras`);
if (reservas.length === 0) {
result.success = true;
result.endTime = new Date();
result.duration = result.endTime.getTime() - startTime.getTime();
logger.syncEnd({ success: true, added: 0, updated: 0, errors: [] });
return result;
}
// Transformar a formato sheet
const sheetData = reservas.map(r =>
this.transformDbToSheet(r as unknown as ReservaRecord, this.config.columnMapping)
);
// Escribir en sheet (simple append/replace)
const headers = this.getSheetHeaders();
// Obtener o crear hoja
const sheets = await sheetsClient.listSheets(this.config.spreadsheetId);
const sheetExists = sheets.some(s => s.name === 'Reservas-Futuras');
if (!sheetExists) {
await sheetsClient.createSheet(this.config.spreadsheetId, 'Reservas-Futuras', headers);
}
// Convertir datos a matriz para escribir
const dataMatrix = sheetData.map(row =>
headers.map(header => row[header] ?? '')
);
// Escribir headers + datos
const allData = [headers, ...dataMatrix];
const endCol = String.fromCharCode(64 + headers.length);
const range = `A1:${endCol}${allData.length}`;
await sheetsClient.writeRange(
this.config.spreadsheetId,
'Reservas-Futuras',
range,
allData
);
result.success = true;
result.rowsAdded = reservas.length;
} catch (error) {
result.errors.push(error instanceof Error ? error.message : String(error));
logger.error('Error en sincronizacion', { error: String(error) });
}
result.endTime = new Date();
result.duration = result.endTime.getTime() - startTime.getTime();
logger.syncEnd({
success: result.success,
added: result.rowsAdded,
updated: result.rowsUpdated,
errors: result.errors
});
return result;
}
// Sincroniza tours/terceros
async syncTercerosTours(): Promise<SyncResult> {
const startTime = new Date();
const result: SyncResult = {
success: false,
startTime,
endTime: new Date(),
duration: 0,
rowsProcessed: 0,
rowsAdded: 0,
rowsUpdated: 0,
rowsDeleted: 0,
errors: []
};
logger.syncStart('Tours y Terceros');
try {
const reservas = await dbClient.getReservasTercerosTours();
result.rowsProcessed = reservas.length;
logger.info(`Obtenidas ${reservas.length} reservas de terceros/tours`);
if (reservas.length === 0) {
result.success = true;
result.endTime = new Date();
result.duration = result.endTime.getTime() - startTime.getTime();
logger.syncEnd({ success: true, added: 0, updated: 0, errors: [] });
return result;
}
const sheetData = reservas.map(r =>
this.transformDbToSheet(r as unknown as ReservaRecord, this.config.columnMapping)
);
const headers = this.getSheetHeaders();
const sheets = await sheetsClient.listSheets(this.config.spreadsheetId);
const sheetExists = sheets.some(s => s.name === 'Tours-Terceros');
if (!sheetExists) {
await sheetsClient.createSheet(this.config.spreadsheetId, 'Tours-Terceros', headers);
}
const dataMatrix = sheetData.map(row =>
headers.map(header => row[header] ?? '')
);
const allData = [headers, ...dataMatrix];
const endCol = String.fromCharCode(64 + headers.length);
const range = `A1:${endCol}${allData.length}`;
await sheetsClient.writeRange(
this.config.spreadsheetId,
'Tours-Terceros',
range,
allData
);
result.success = true;
result.rowsAdded = reservas.length;
} catch (error) {
result.errors.push(error instanceof Error ? error.message : String(error));
logger.error('Error en sincronizacion', { error: String(error) });
}
result.endTime = new Date();
result.duration = result.endTime.getTime() - startTime.getTime();
logger.syncEnd({
success: result.success,
added: result.rowsAdded,
updated: result.rowsUpdated,
errors: result.errors
});
return result;
}
// Sincronizacion completa
async syncAll(): Promise<void> {
logger.info('Iniciando sincronizacion completa...');
const futuras = await this.syncReservasFuturas();
const terceros = await this.syncTercerosTours();
const totalProcessed = futuras.rowsProcessed + terceros.rowsProcessed;
const totalAdded = futuras.rowsAdded + terceros.rowsAdded;
const allSuccess = futuras.success && terceros.success;
logger.info(`Sincronizacion completa: ${totalProcessed} filas procesadas, ${totalAdded} agregadas, exito: ${allSuccess}`);
}
// Inicializa clientes
async initialize(): Promise<boolean> {
try {
const credentials = loadGoogleCredentials();
await sheetsClient.initialize(credentials);
logger.info('Cliente de Sheets inicializado');
if (process.env.DATABASE_URL) {
await dbClient.initialize(process.env.DATABASE_URL);
logger.info('Cliente de DB inicializado');
} else {
logger.warn('DATABASE_URL no configurado, funciones de DB no disponibles');
return false;
}
return true;
} catch (error) {
logger.error('Error inicializando clientes', { error: String(error) });
return false;
}
}
// Ejecuta una vez
async runOnce(): Promise<void> {
const initialized = await this.initialize();
if (!initialized) {
process.exit(1);
}
await this.syncAll();
logger.info('Sincronizacion unica completada');
process.exit(0);
}
// Ejecuta con cron
async runCron(): Promise<void> {
const initialized = await this.initialize();
if (!initialized) {
process.exit(1);
}
const schedule = this.config.cronSchedule;
logger.info(`Iniciando cron con schedule: ${schedule}`);
// Ejecutar inmediatamente
await this.syncAll();
// Programar ejecuciones
cron.schedule(schedule, async () => {
logger.info('Ejecutando sincronizacion programada...');
await this.syncAll();
});
logger.info('Daemon de sincronizacion iniciado. Presiona Ctrl+C para detener.');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN
// ═══════════════════════════════════════════════════════════════════════════
async function main() {
const args = process.argv.slice(2);
const cli = new SyncCLI();
if (args.includes('--help') || args.includes('-h')) {
console.log(`
MCP Spreadsheets - CLI de Sincronizacion
=========================================
Uso: npx tsx src/cli/sync.ts [opciones]
Opciones:
--once Ejecuta sincronizacion una vez y termina
--cron Ejecuta como daemon con schedule configurado
--help, -h Muestra esta ayuda
Variables de entorno requeridas:
DATABASE_URL URL de PostgreSQL
GOOGLE_SPREADSHEET_ID ID del Google Spreadsheet
GOOGLE_SERVICE_ACCOUNT_* Credenciales de Google
Opcionales:
SYNC_CRON_SCHEDULE Expresion cron (default: */15 * * * *)
LOG_LEVEL Nivel de log: debug, info, warn, error
`);
process.exit(0);
}
if (args.includes('--once') || args.includes('--sync')) {
await cli.runOnce();
} else if (args.includes('--cron') || args.includes('--daemon')) {
await cli.runCron();
} else {
// Default: ejecutar una vez
await cli.runOnce();
}
}
main().catch(error => {
logger.error('Error fatal', { error: String(error) });
process.exit(1);
});