404 lines
15 KiB
TypeScript
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();
|