almeira-web/transporte-cali-buenaventura/backend/src/services/messaging-flete.service.ts

404 lines
15 KiB
TypeScript

import { Types } from 'mongoose';
import { User } from '../models/User';
import { Trip, ITrip, IPackageInfo } from '../models/Trip';
import { TarifasConfig, ITarifasConfig } from '../models/TarifasConfig';
import { boldPaymentService, BoldPaymentResponse } from './bold.service';
import { couponService } from './coupon.service';
import { whatsAppNotificationService } from './whatsapp-notification.service';
import { isDatabaseConnected } from '../config/database';
import { tariffsService } from './tariffs.service';
import { inMemoryTripsService } from './in-memory-trips.service';
export interface PackageQuoteRequest {
remitenteId?: string;
remitenteNombre?: string;
remitenteTelefono?: string;
remitenteEmail?: string;
direccionRecogida?: string;
coordenadasRecogida?: [number, number]; // [lng, lat]
ciudadOrigen?: 'CALI' | 'BUENAVENTURA';
destinatarioNombre?: string;
destinatarioTelefono?: string;
direccionEntrega?: string;
coordenadasEntrega?: [number, number]; // [lng, lat]
ciudadDestino?: 'CALI' | 'BUENAVENTURA';
tipoBulto?: 'SOBRE' | 'MEDIANO' | 'GRANDE';
tipoPaquete?: string;
descripcion?: string;
descripcionContenido?: string;
pesoAproxKg?: number;
incluirSeguro?: boolean;
codigoCupon?: string;
modalidadTiempo?: 'INMEDIATO' | 'PROGRAMADO';
tipoProgramacion?: 'HORA_RECOGIDA' | 'HORA_LLEGADA_DESTINO';
fechaProgramada?: string;
horaProgramada?: string;
horaSugeridaRecogida?: string;
esEnvioUrgente?: boolean;
// Aliases from front
telefonoRemitente?: string;
telefonoDestinatario?: string;
}
export interface PackageFareBreakdown {
distanciaKm: number;
precioBase: number;
costoPorKm: number;
subtotalDistancia: number;
factorBulto: number;
tipoBulto: string;
valorSeguro: number;
recargoUrgencia?: number;
esEnvioUrgente?: boolean;
modalidadTiempo?: 'INMEDIATO' | 'PROGRAMADO';
subtotalOriginal?: number;
descuentoCupon?: {
codigo: string;
tipo: string;
valor: number;
montoDescontado: number;
};
tarifaTotal: number;
porcentajeComisionAdmin: number;
comisionAdmin: number;
pagoConductor: number;
prepagoRequerido: boolean;
}
export interface PackageBookingResult {
success: boolean;
message: string;
tripId?: string;
guiaSeguimiento?: string;
tarifaCalculada?: number;
tarifaFinal?: number;
descuentoCupon?: any;
boldPaymentLink?: string;
trip?: any;
desglose: PackageFareBreakdown;
boldPayment?: BoldPaymentResponse;
}
export class MessagingFleteService {
private static instance: MessagingFleteService;
private constructor() {}
public static getInstance(): MessagingFleteService {
if (!MessagingFleteService.instance) {
MessagingFleteService.instance = new MessagingFleteService();
}
return MessagingFleteService.instance;
}
/**
* Calcula la distancia geodésica exacta en kilómetros entre dos coordenadas usando Haversine
*/
public calcularDistanciaKm(coord1: [number, number], coord2: [number, number]): number {
const [lon1, lat1] = coord1;
const [lon2, lat2] = coord2;
const R = 6371; // Radio de la Tierra en km
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distancia = R * c;
// Redondear a 2 decimales y garantizar mínimo de 5 km para trayectos locales
return Math.max(5, Math.round(distancia * 100) / 100);
}
/**
* Cotiza el flete aplicando estrictamente la fórmula de ingeniería definida:
* Tarifa Total = (Base + (Distancia Km * Costo Km)) * Factor Tamaño/Peso + Seguro
*/
public async cotizarFlete(
distanciaKm: number,
tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE',
incluirSeguro: boolean = true,
esEnvioUrgente: boolean = false
): Promise<PackageFareBreakdown> {
const config = await tariffsService.getConfig();
const precioBase = config.precioBasePaquete || 12000;
const costoPorKm = config.precioKmPaquete || 750;
const recargoUrgencia = esEnvioUrgente ? (config.recargoEnvioUrgente || 15000) : 0;
let factor = 1.0;
if (tipoBulto === 'SOBRE') factor = config.factorPaquetes.sobre || 1.0;
else if (tipoBulto === 'MEDIANO') factor = config.factorPaquetes.mediano || 1.5;
else if (tipoBulto === 'GRANDE') factor = config.factorPaquetes.grande || 2.0;
const seguro = incluirSeguro ? config.seguroBasePaquete || 5000 : 0;
// Fórmula maestra de ingeniería con recargo opcional por urgencia
const subtotalKm = distanciaKm * costoPorKm;
const tarifaBruta = (precioBase + subtotalKm) * factor + seguro + recargoUrgencia;
const tarifaTotal = Math.round(tarifaBruta / 500) * 500; // Redondeado comercialmente a múltiplos de $500 COP
const comisionAdmin = Math.round(tarifaTotal * (config.porcentajeComisionAdmin / 100));
const pagoConductor = tarifaTotal - comisionAdmin;
return {
distanciaKm,
precioBase,
costoPorKm,
subtotalDistancia: Math.round(subtotalKm),
factorBulto: factor,
tipoBulto,
valorSeguro: seguro,
recargoUrgencia,
esEnvioUrgente,
modalidadTiempo: esEnvioUrgente ? 'INMEDIATO' : 'PROGRAMADO',
tarifaTotal,
porcentajeComisionAdmin: config.porcentajeComisionAdmin,
comisionAdmin,
pagoConductor,
prepagoRequerido: true, // 100% prepagado obligatorio
};
}
/**
* Crea la orden de mensajería, genera el cobro en Bold (QR Bre-B / PSE / Nequi / Tarjetas)
* y registra el servicio en estado PENDING_APPROVAL
*/
public async registrarEnvioPaquete(request: PackageQuoteRequest): Promise<PackageBookingResult> {
// 1. Normalizar parámetros de entrada (soporte para aliases y valores por defecto)
const remitenteNombre = (request.remitenteNombre || 'Remitente').trim();
const remitenteTelefono = (request.remitenteTelefono || request.telefonoRemitente || '573000000000').replace(/\D/g, '');
const remitenteEmail = request.remitenteEmail || '';
const destinatarioNombre = (request.destinatarioNombre || 'Destinatario Encomienda').trim();
const destinatarioTelefono = (request.destinatarioTelefono || request.telefonoDestinatario || '573000000000').replace(/\D/g, '');
const direccionRecogida = request.direccionRecogida || 'Cali, Valle del Cauca';
const direccionEntrega = request.direccionEntrega || 'Buenaventura, Valle del Cauca';
const coordenadasRecogida = request.coordenadasRecogida || [-76.5320, 3.4215];
const coordenadasEntrega = request.coordenadasEntrega || [-77.0250, 3.8820];
const ciudadOrigen = request.ciudadOrigen || 'CALI';
const ciudadDestino = request.ciudadDestino || 'BUENAVENTURA';
const descripcion = request.descripcion || request.descripcionContenido || 'Encomienda / Paquete';
// Determinar tipo de bulto de forma inteligente
let tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE' = request.tipoBulto || 'MEDIANO';
if (!request.tipoBulto && request.tipoPaquete) {
const tp = request.tipoPaquete.toLowerCase();
if (tp.includes('sobre') || tp.includes('doc')) tipoBulto = 'SOBRE';
else if (tp.includes('grande') || tp.includes('pesada') || tp.includes('carga')) tipoBulto = 'GRANDE';
else tipoBulto = 'MEDIANO';
}
// 2. Validar penalidades si la base de datos está en línea y se proporcionó remitenteId
if (isDatabaseConnected() && request.remitenteId && Types.ObjectId.isValid(request.remitenteId)) {
try {
const remitente = await User.findById(request.remitenteId);
if (remitente && remitente.saldoPenalidad > 0) {
throw new Error(
`El usuario presenta una penalidad pendiente de $${remitente.saldoPenalidad.toLocaleString('es-CO')} COP. No puede solicitar encomiendas.`
);
}
} catch (err: any) {
if (err.message && err.message.includes('penalidad')) throw err;
console.warn('⚠️ [Aviso DB remitente]:', err.message);
}
}
// 3. Determinar si es envío urgente o programado
const isUrgent = request.esEnvioUrgente ?? (request.modalidadTiempo === 'INMEDIATO' || request.modalidadTiempo === undefined);
// 4. Calcular distancia y cotizar flete
const distanciaKm = this.calcularDistanciaKm(coordenadasRecogida, coordenadasEntrega);
const desglose = await this.cotizarFlete(distanciaKm, tipoBulto, request.incluirSeguro ?? true, isUrgent);
const subtotalBruto = desglose.tarifaTotal;
let descuentoTotal = 0;
let cuponDescInfo: any = null;
if (request.codigoCupon) {
try {
const valCup = await couponService.validateCoupon(request.codigoCupon, 'ENCOMIENDAS', subtotalBruto);
if (valCup.valid && valCup.descuentoCalculado > 0) {
descuentoTotal = valCup.descuentoCalculado;
cuponDescInfo = {
codigo: valCup.coupon!.codigo,
tipo: valCup.coupon!.tipoDescuento,
valor: valCup.coupon!.valorDescuento,
montoDescontado: descuentoTotal,
};
desglose.subtotalOriginal = subtotalBruto;
desglose.descuentoCupon = cuponDescInfo;
desglose.tarifaTotal = Math.max(0, subtotalBruto - descuentoTotal);
desglose.comisionAdmin = Math.round(desglose.tarifaTotal * (desglose.porcentajeComisionAdmin / 100));
desglose.pagoConductor = desglose.tarifaTotal - desglose.comisionAdmin;
await couponService.redeemCoupon(valCup.coupon!.codigo);
console.log(`🎟️ [Cupón Aplicado en Encomienda] Código: ${valCup.coupon!.codigo}, Descuento: $${descuentoTotal} COP`);
}
} catch (errCup) {
console.warn('⚠️ [Error validando cupón en flete]:', errCup);
}
}
const paqueteInfo: IPackageInfo = {
remitente: {
nombre: remitenteNombre,
telefono: remitenteTelefono,
direccionRecogida,
},
destinatario: {
nombre: destinatarioNombre,
telefono: destinatarioTelefono,
direccionEntrega,
},
tipoBulto,
descripcion,
pesoAproxKg: request.pesoAproxKg,
distanciaKm,
seguroIncluido: request.incluirSeguro ?? true,
};
const tripId = new Types.ObjectId().toString();
// 5. Crear el Trip en estado PENDING_APPROVAL con modalidad de tiempo
const tripData: any = {
_id: tripId,
tipoServicio: 'MENSAJERIA',
modalidadTiempo: request.modalidadTiempo || (isUrgent ? 'INMEDIATO' : 'PROGRAMADO'),
tipoProgramacion: request.tipoProgramacion,
fechaProgramada: request.fechaProgramada,
horaProgramada: request.horaProgramada,
horaSugeridaRecogida: request.horaSugeridaRecogida,
esEnvioUrgente: isUrgent,
recargoUrgencia: desglose.recargoUrgencia || 0,
origen: {
direccion: direccionRecogida,
ubicacion: {
type: 'Point',
coordinates: coordenadasRecogida,
},
esPuntoFijo: false,
ciudad: ciudadOrigen,
},
destino: {
direccion: direccionEntrega,
ubicacion: {
type: 'Point',
coordinates: coordenadasEntrega,
},
esPuntoFijo: false,
ciudad: ciudadDestino,
},
pasajeros: [],
paqueteInfo,
estado: 'PENDING_APPROVAL',
metodoPago: 'BOLD',
tarifaTotal: desglose.tarifaTotal,
precio: desglose.tarifaTotal,
descuentoCupon: cuponDescInfo || undefined,
comisionAdmin: desglose.comisionAdmin,
pagoConductor: desglose.pagoConductor,
horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora
secuenciaRutaWaze: [
{
orden: 1,
direccion: direccionRecogida,
lat: coordenadasRecogida[1],
lng: coordenadasRecogida[0],
wazeDeepLink: `https://waze.com/ul?ll=${coordenadasRecogida[1]},${coordenadasRecogida[0]}&navigate=yes`,
completada: false,
},
{
orden: 2,
direccion: direccionEntrega,
lat: coordenadasEntrega[1],
lng: coordenadasEntrega[0],
wazeDeepLink: `https://waze.com/ul?ll=${coordenadasEntrega[1]},${coordenadasEntrega[0]}&navigate=yes`,
completada: false,
},
],
createdAt: new Date(),
};
// Guardar en caché autónoma en memoria siempre
inMemoryTripsService.saveTrip(tripData);
// Guardar en MongoDB si está conectado
if (isDatabaseConnected()) {
try {
const mongoTrip = new Trip(tripData);
await mongoTrip.save();
} catch (saveErr) {
console.warn('⚠️ [Aviso al guardar paquete en MongoDB]:', saveErr);
}
}
// Notificar por WhatsApp a la central / admin
const horarioEnvioStr = !isUrgent
? `${request.fechaProgramada || ''} ${request.horaProgramada ? `(${request.tipoProgramacion === 'HORA_LLEGADA_DESTINO' ? 'Entrega requerida: ' : 'Recogida: '}${request.horaProgramada})` : ''}`
: `Despacho Inmediato Prioritario (Recargo Urgencia: +$${(desglose.recargoUrgencia || 0).toLocaleString('es-CO')} COP)`;
whatsAppNotificationService.notificarAdminNuevoServicio({
tipo: 'MENSAJERIA',
tripId,
solicitante: remitenteNombre,
telefono: remitenteTelefono,
origen: direccionRecogida,
destino: direccionEntrega,
valorTotal: desglose.tarifaTotal,
pasajerosCount: 0,
modalidad: isUrgent ? '🚨 ENVÍO URGENTE INMEDIATO' : '📅 ENVÍO PROGRAMADO',
detalleHorario: horarioEnvioStr,
}).catch((err) => console.warn('⚠️ [WhatsApp notify admin package error]:', err));
// 6. Generar cobro oficial 100% prepagado a través de Bold Smart Checkout
let boldRes: any = null;
try {
boldRes = await boldPaymentService.createPayment({
referenceId: tripId,
amount: desglose.tarifaTotal,
description: `Flete Encomienda Cali-Buenaventura #${tripId.slice(-6)} - Bulto: ${tipoBulto}${isUrgent ? ' (Urgente)' : ''}${cuponDescInfo ? ` (Bono ${cuponDescInfo.codigo})` : ''}`,
payerEmail: remitenteEmail || 'cliente@pacifictrans.co',
payerPhone: remitenteTelefono,
paymentType: 'MESSAGING_PACKAGE',
});
if (boldRes && boldRes.success) {
tripData.boldPayment = {
paymentLinkId: boldRes.paymentLinkId,
checkoutUrl: boldRes.checkoutUrl,
qrImageUrl: boldRes.qrImageUrl,
transactionStatus: 'ACTIVE',
};
inMemoryTripsService.saveTrip(tripData);
}
} catch (errBold) {
console.warn('⚠️ [Bold Gateway Notice]:', errBold);
}
return {
success: true,
message: 'Servicio de mensajería cotizado y registrado. Pago en línea seguro generado con éxito.',
tripId,
guiaSeguimiento: `PT-${tripId.slice(-6).toUpperCase()}`,
tarifaCalculada: desglose.tarifaTotal,
tarifaFinal: desglose.tarifaTotal,
descuentoCupon: cuponDescInfo,
desglose,
boldPayment: boldRes,
boldPaymentLink: boldRes?.checkoutUrl,
trip: tripData,
};
}
}
export const messagingFleteService = MessagingFleteService.getInstance();