almeira-web/transporte-cali-buenaventura/backend/src/services/trip-scheduling.service.ts

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();