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

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