259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
import { Types } from 'mongoose';
|
|
import { Trip, ITrip } from '../models/Trip';
|
|
import { User } from '../models/User';
|
|
import { DriverProfile } from '../models/DriverProfile';
|
|
import { whatsAppNotificationService } from './whatsapp-notification.service';
|
|
import { pushNotificationService } from './push-notification.service';
|
|
|
|
export interface ScheduleTripParams {
|
|
tripId: string;
|
|
adminId: string;
|
|
conductorId: string;
|
|
fechaHoraSalida: Date | string;
|
|
minutosEntreParadas?: number; // Tiempo estimado entre recogida y recogida (default: 15 min)
|
|
}
|
|
|
|
export class TripSchedulingService {
|
|
private static instance: TripSchedulingService;
|
|
|
|
private constructor() {}
|
|
|
|
public static getInstance(): TripSchedulingService {
|
|
if (!TripSchedulingService.instance) {
|
|
TripSchedulingService.instance = new TripSchedulingService();
|
|
}
|
|
return TripSchedulingService.instance;
|
|
}
|
|
|
|
/**
|
|
* Obtiene todos los viajes que tienen cupo completo (4/4) o están listos para ser programados
|
|
*/
|
|
public async getViajesListosParaProgramar(): Promise<ITrip[]> {
|
|
return Trip.find({
|
|
estado: { $in: ['POOL_COMPLETED', 'PENDING_APPROVAL'] },
|
|
tipoServicio: 'TRANSPORTE',
|
|
})
|
|
.populate('pasajeros.usuarioId', 'nombre telefono')
|
|
.sort({ cupoCompleto: -1, createdAt: 1 })
|
|
.exec();
|
|
}
|
|
|
|
/**
|
|
* Programa formalmente la hora de salida del viaje, asigna conductor y calcula
|
|
* los horarios escalonados de recogida para cada pasajero según la ruta Waze
|
|
*/
|
|
public async programarViaje(params: ScheduleTripParams): Promise<ITrip> {
|
|
const { tripId, adminId, conductorId, minutosEntreParadas = 15 } = params;
|
|
const horaSalida = new Date(params.fechaHoraSalida);
|
|
|
|
const trip = await Trip.findById(tripId);
|
|
if (!trip) throw new Error(`Viaje ${tripId} no encontrado.`);
|
|
|
|
const conductor = await User.findById(conductorId);
|
|
if (!conductor || conductor.rol !== 'CONDUCTOR') {
|
|
throw new Error('Conductor no válido o no registrado con rol CONDUCTOR.');
|
|
}
|
|
|
|
const driverProfile = await DriverProfile.findOne({ userId: conductor._id });
|
|
if (!driverProfile) {
|
|
throw new Error('El conductor seleccionado no tiene perfil vehicular registrado.');
|
|
}
|
|
|
|
const admin = await User.findById(adminId);
|
|
|
|
// 1. Asignar y actualizar campos de programación
|
|
trip.conductorId = conductor._id;
|
|
trip.fechaHoraSalidaProgramada = horaSalida;
|
|
trip.estado = 'SCHEDULED';
|
|
trip.aprobadoPorAdmin = {
|
|
adminId: admin ? admin._id : new Types.ObjectId(),
|
|
fechaAprobacion: new Date(),
|
|
};
|
|
|
|
// 2. Calcular los horarios de recogida escalonados por pasajero
|
|
// Si la salida final hacia la autopista es a la horaSalida, calculamos hacia atrás para cada parada
|
|
const totalParadas = trip.pasajeros.length;
|
|
const itinerarioParaConductor: Array<{
|
|
orden: number;
|
|
nombre: string;
|
|
telefono: string;
|
|
direccionRecogida: string;
|
|
estadoPago: string;
|
|
valor: number;
|
|
horaProgramada: string;
|
|
}> = [];
|
|
|
|
trip.pasajeros.forEach((p, idx) => {
|
|
// El orden de recogida (1 a 4): La primera parada se realiza (totalParadas - idx) * minutos antes
|
|
const minutosAntes = (totalParadas - idx) * minutosEntreParadas;
|
|
const horaRecogidaDate = new Date(horaSalida.getTime() - minutosAntes * 60 * 1000);
|
|
const horaRecogidaStr = horaRecogidaDate.toLocaleTimeString('es-CO', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
|
|
p.horaEstimadaRecogida = horaRecogidaStr;
|
|
|
|
itinerarioParaConductor.push({
|
|
orden: idx + 1,
|
|
nombre: p.nombre,
|
|
telefono: p.telefono,
|
|
direccionRecogida: p.direccionRecogida,
|
|
estadoPago:
|
|
p.estadoPago === 'PAGADO_BOLD'
|
|
? 'Pagado vía Bold'
|
|
: p.estadoPago === 'PAGADO_NEQUI'
|
|
? 'Pagado vía Nequi'
|
|
: 'Pago pendiente en efectivo',
|
|
valor: p.valor,
|
|
horaProgramada: horaRecogidaStr,
|
|
});
|
|
});
|
|
|
|
trip.horaEstimadaRecogida = new Date(
|
|
horaSalida.getTime() - totalParadas * minutosEntreParadas * 60 * 1000
|
|
);
|
|
|
|
await trip.save();
|
|
|
|
console.log(
|
|
`🗓️ [Viaje Programado] #${trip._id} programado para salir a las ${horaSalida.toLocaleString(
|
|
'es-CO'
|
|
)} con Conductor ${conductor.nombre}`
|
|
);
|
|
|
|
const horaSalidaFormateada = horaSalida.toLocaleTimeString('es-CO', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: true,
|
|
});
|
|
const fechaFormateada = horaSalida.toLocaleDateString('es-CO', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
});
|
|
|
|
// 3. Disparar notificaciones WhatsApp personalizadas a CADA UNO DE LOS PASAJEROS
|
|
for (const p of trip.pasajeros) {
|
|
const msgPasajero =
|
|
`✅ *¡TU VIAJE HA SIDO PROGRAMADO OFICIALMENTE!*\n\n` +
|
|
`Hola *${p.nombre}*, la central de despacho ha fijado la programación de tu viaje *${trip.origen.ciudad} ➔ ${trip.destino.ciudad}* (#${trip._id
|
|
.toString()
|
|
.slice(-6)}).\n\n` +
|
|
`📅 *Fecha:* ${fechaFormateada}\n` +
|
|
`⏰ *Tu hora exacta de recogida:* ${p.horaEstimadaRecogida}\n` +
|
|
`📍 *Dirección:* ${p.direccionRecogida}\n\n` +
|
|
`🚗 *Datos del Vehículo Asignado:*\n` +
|
|
`• *Conductor:* ${conductor.nombre}\n` +
|
|
`• *Vehículo:* ${driverProfile.modeloVehiculo} (${driverProfile.colorVehiculo || 'Blanco'})\n` +
|
|
`• *Placa:* ${driverProfile.placaVehiculo}\n` +
|
|
`• *Teléfono:* +${conductor.telefono.replace(/\D/g, '')}\n\n` +
|
|
`💳 *Estado de Pago:* ${p.estadoPago === 'PAGADO_BOLD' ? 'Pagado vía Bold' : 'Pendiente en efectivo'}\n\n` +
|
|
`_Por favor permanece listo 5 minutos antes de tu hora de recogida._`;
|
|
|
|
await whatsAppNotificationService.sendTextMessage(p.telefono, msgPasajero);
|
|
}
|
|
|
|
// 4. Disparar notificación WhatsApp al CONDUCTOR con itinerario cronológico y Waze
|
|
let cuerpoItinerario = '';
|
|
itinerarioParaConductor.forEach((item) => {
|
|
cuerpoItinerario +=
|
|
`\n⏰ *${item.horaProgramada}* - Parada ${item.orden}: *${item.nombre}*\n` +
|
|
`• Tel: +${item.telefono.replace(/\D/g, '')}\n` +
|
|
`• Dir: ${item.direccionRecogida}\n` +
|
|
`• Pago: ${item.estadoPago} ($${item.valor.toLocaleString('es-CO')})\n`;
|
|
});
|
|
|
|
const waze1 = trip.secuenciaRutaWaze?.[0]?.wazeDeepLink || '';
|
|
const msgConductor =
|
|
`🗓️ *ITINERARIO DE VIAJE PROGRAMADO*\n\n` +
|
|
`¡Hola *${conductor.nombre}*! Se ha programado tu viaje *${trip.origen.ciudad} ➔ ${trip.destino.ciudad}* (#${trip._id
|
|
.toString()
|
|
.slice(-6)}).\n\n` +
|
|
`📅 *Fecha:* ${fechaFormateada}\n` +
|
|
`🚀 *Hora Inicio Primera Recogida:* ${itinerarioParaConductor[0]?.horaProgramada || horaSalidaFormateada}\n` +
|
|
`🏁 *Hora Estimada Salida Vía Principal:* ${horaSalidaFormateada}\n\n` +
|
|
`📋 *Secuencia de Paradas (4 Pasajeros):*` +
|
|
cuerpoItinerario +
|
|
`\n💰 *Tu Pago Neto:* $${trip.pagoConductor.toLocaleString('es-CO')} COP\n\n` +
|
|
`🗺️ *Iniciar Waze a la Primera Parada:*\n👉 ${waze1}`;
|
|
|
|
await whatsAppNotificationService.sendTextMessage(conductor.telefono, msgConductor);
|
|
|
|
return trip;
|
|
}
|
|
|
|
/**
|
|
* Sugiere conductores disponibles ordenados según qué tan cerca está su lugar de residencia
|
|
* de los domicilios de recogida de los pasajeros en Cali
|
|
*/
|
|
public async sugerirConductoresPorCercaniaResidencia(tripId: string): Promise<
|
|
Array<{
|
|
conductorId: string;
|
|
nombre: string;
|
|
telefono: string;
|
|
placaVehiculo: string;
|
|
modeloVehiculo: string;
|
|
colorVehiculo?: string;
|
|
distanciaKmAResidencia: number;
|
|
direccionPrimeraParada: string;
|
|
}>
|
|
> {
|
|
const trip = await Trip.findById(tripId);
|
|
if (!trip) throw new Error('Viaje no encontrado');
|
|
|
|
const primeraParada = trip.secuenciaRutaWaze?.[0] || {
|
|
lat: trip.pasajeros[0]?.coordenadas.coordinates[1],
|
|
lng: trip.pasajeros[0]?.coordenadas.coordinates[0],
|
|
direccion: trip.pasajeros[0]?.direccionRecogida,
|
|
};
|
|
|
|
if (!primeraParada.lat || !primeraParada.lng) {
|
|
return [];
|
|
}
|
|
|
|
const conductores = await DriverProfile.find({ estaActivo: true })
|
|
.populate('userId', 'nombre telefono')
|
|
.lean();
|
|
|
|
const ranking = [];
|
|
|
|
for (const d of conductores as any[]) {
|
|
if (!d.userId) continue;
|
|
const coords = d.zonaResidencia?.coordinates || d.coordenadasActuales?.coordinates;
|
|
if (!coords) continue;
|
|
|
|
const distMetros = this.calcularDistanciaMetros(primeraParada.lat, primeraParada.lng, coords[1], coords[0]);
|
|
ranking.push({
|
|
conductorId: d.userId._id.toString(),
|
|
nombre: d.userId.nombre,
|
|
telefono: d.userId.telefono,
|
|
placaVehiculo: d.placaVehiculo,
|
|
modeloVehiculo: d.modeloVehiculo,
|
|
colorVehiculo: d.colorVehiculo,
|
|
distanciaKmAResidencia: +(distMetros / 1000).toFixed(2),
|
|
direccionPrimeraParada: primeraParada.direccion,
|
|
});
|
|
}
|
|
|
|
// Ordenar de menor a mayor distancia: el conductor que vive más cerca de la primera parada va de primero
|
|
ranking.sort((a, b) => a.distanciaKmAResidencia - b.distanciaKmAResidencia);
|
|
return ranking;
|
|
}
|
|
|
|
private calcularDistanciaMetros(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
const R = 6371e3;
|
|
const phi1 = (lat1 * Math.PI) / 180;
|
|
const phi2 = (lat2 * Math.PI) / 180;
|
|
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180;
|
|
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180;
|
|
const a =
|
|
Math.sin(deltaPhi / 2) ** 2 +
|
|
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
}
|
|
|
|
export const tripSchedulingService = TripSchedulingService.getInstance();
|