255 lines
8.4 KiB
TypeScript
255 lines
8.4 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';
|
|
|
|
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';
|
|
descripcion: string;
|
|
pesoAproxKg?: number;
|
|
incluirSeguro?: boolean;
|
|
}
|
|
|
|
export interface PackageFareBreakdown {
|
|
distanciaKm: number;
|
|
precioBase: number;
|
|
costoPorKm: number;
|
|
subtotalDistancia: number;
|
|
factorBulto: number;
|
|
tipoBulto: string;
|
|
valorSeguro: number;
|
|
tarifaTotal: number;
|
|
porcentajeComisionAdmin: number;
|
|
comisionAdmin: number;
|
|
pagoConductor: number;
|
|
prepagoRequerido: boolean;
|
|
}
|
|
|
|
export interface PackageBookingResult {
|
|
success: boolean;
|
|
message: string;
|
|
tripId?: string;
|
|
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
|
|
): Promise<PackageFareBreakdown> {
|
|
const config: ITarifasConfig =
|
|
(await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({}));
|
|
|
|
const precioBase = config.precioBasePaquete || 12000;
|
|
const costoPorKm = config.precioKmPaquete || 750;
|
|
|
|
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
|
|
const subtotalKm = distanciaKm * costoPorKm;
|
|
const tarifaBruta = (precioBase + subtotalKm) * factor + seguro;
|
|
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,
|
|
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. Validar remitente y verificar que no tenga sanciones monetarias
|
|
const remitente = await User.findById(request.remitenteId);
|
|
if (!remitente) {
|
|
throw new Error('El usuario remitente no está registrado en el sistema.');
|
|
}
|
|
|
|
if (remitente.saldoPenalidad > 0) {
|
|
throw new Error(
|
|
`El usuario presenta una penalidad pendiente de $${remitente.saldoPenalidad.toLocaleString('es-CO')} COP. No puede solicitar encomiendas.`
|
|
);
|
|
}
|
|
|
|
// 2. Calcular distancia y cotizar flete
|
|
const distanciaKm = this.calcularDistanciaKm(request.coordenadasRecogida, request.coordenadasEntrega);
|
|
const desglose = await this.cotizarFlete(distanciaKm, request.tipoBulto, request.incluirSeguro ?? true);
|
|
|
|
const paqueteInfo: IPackageInfo = {
|
|
remitente: {
|
|
nombre: request.remitenteNombre,
|
|
telefono: request.remitenteTelefono,
|
|
direccionRecogida: request.direccionRecogida,
|
|
},
|
|
destinatario: {
|
|
nombre: request.destinatarioNombre,
|
|
telefono: request.destinatarioTelefono,
|
|
direccionEntrega: request.direccionEntrega,
|
|
},
|
|
tipoBulto: request.tipoBulto,
|
|
descripcion: request.descripcion,
|
|
pesoAproxKg: request.pesoAproxKg,
|
|
distanciaKm,
|
|
seguroIncluido: request.incluirSeguro ?? true,
|
|
};
|
|
|
|
// 3. Crear el Trip en estado PENDING_APPROVAL
|
|
const trip = new Trip({
|
|
tipoServicio: 'MENSAJERIA',
|
|
origen: {
|
|
direccion: request.direccionRecogida,
|
|
ubicacion: {
|
|
type: 'Point',
|
|
coordinates: request.coordenadasRecogida,
|
|
},
|
|
esPuntoFijo: false,
|
|
ciudad: request.ciudadOrigen,
|
|
},
|
|
destino: {
|
|
direccion: request.direccionEntrega,
|
|
ubicacion: {
|
|
type: 'Point',
|
|
coordinates: request.coordenadasEntrega,
|
|
},
|
|
esPuntoFijo: false,
|
|
ciudad: request.ciudadDestino,
|
|
},
|
|
pasajeros: [],
|
|
paqueteInfo,
|
|
estado: 'PENDING_APPROVAL',
|
|
metodoPago: 'BOLD',
|
|
tarifaTotal: desglose.tarifaTotal,
|
|
comisionAdmin: desglose.comisionAdmin,
|
|
pagoConductor: desglose.pagoConductor,
|
|
horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora
|
|
secuenciaRutaWaze: [
|
|
{
|
|
orden: 1,
|
|
direccion: request.direccionRecogida,
|
|
lat: request.coordenadasRecogida[1],
|
|
lng: request.coordenadasRecogida[0],
|
|
wazeDeepLink: `https://waze.com/ul?ll=${request.coordenadasRecogida[1]},${request.coordenadasRecogida[0]}&navigate=yes`,
|
|
completada: false,
|
|
},
|
|
{
|
|
orden: 2,
|
|
direccion: request.direccionEntrega,
|
|
lat: request.coordenadasEntrega[1],
|
|
lng: request.coordenadasEntrega[0],
|
|
wazeDeepLink: `https://waze.com/ul?ll=${request.coordenadasEntrega[1]},${request.coordenadasEntrega[0]}&navigate=yes`,
|
|
completada: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
await trip.save();
|
|
|
|
// 4. Generar cobro oficial 100% prepagado a través de Bold Smart Checkout
|
|
const boldRes = await boldPaymentService.createPayment({
|
|
referenceId: trip._id.toString(),
|
|
amount: desglose.tarifaTotal,
|
|
description: `Flete Encomienda Cali-Buenaventura #${trip._id.toString().slice(-6)} - Bulto: ${request.tipoBulto}`,
|
|
payerEmail: request.remitenteEmail || remitente.email,
|
|
payerPhone: request.remitenteTelefono || remitente.telefono,
|
|
paymentType: 'MESSAGING_PACKAGE',
|
|
});
|
|
|
|
if (boldRes.success) {
|
|
trip.boldPayment = {
|
|
paymentLinkId: boldRes.paymentLinkId,
|
|
checkoutUrl: boldRes.checkoutUrl,
|
|
qrImageUrl: boldRes.qrImageUrl,
|
|
transactionStatus: 'ACTIVE',
|
|
};
|
|
await trip.save();
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Servicio de mensajería cotizado y registrado. Pago en línea 100% generado en Bold.',
|
|
tripId: trip._id.toString(),
|
|
desglose,
|
|
boldPayment: boldRes,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const messagingFleteService = MessagingFleteService.getInstance();
|