feat(pacifictrans-go): arquitectura fullstack Cali-Buenaventura con geo-pooling MongoDB, Appwrite, Bold y WhatsApp
This commit is contained in:
parent
9b5f1205a6
commit
0cb63165cb
2
.gitignore
vendored
2
.gitignore
vendored
@ -22,6 +22,8 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
**/dist/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
42
pacifictrans-app/android/app/build.gradle
Normal file
42
pacifictrans-app/android/app/build.gradle
Normal file
@ -0,0 +1,42 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'co.pacifictrans.go'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "co.pacifictrans.go"
|
||||
minSdk 24
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix ""
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all {
|
||||
outputFileName = "PacifictransGo.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation project(':capacitor-android')
|
||||
}
|
||||
26
pacifictrans-app/capacitor.config.ts
Normal file
26
pacifictrans-app/capacitor.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'co.pacifictrans.go',
|
||||
appName: 'Pacifictrans Go',
|
||||
webDir: 'public',
|
||||
server: {
|
||||
url: 'https://transporte.almeira.com.co',
|
||||
cleartext: true,
|
||||
androidScheme: 'https',
|
||||
},
|
||||
android: {
|
||||
appendUserAgent: 'CapacitorApp/PacifictransGo',
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchShowDuration: 3000,
|
||||
launchAutoHide: true,
|
||||
backgroundColor: '#0f172a',
|
||||
androidScaleType: 'CENTER_CROP',
|
||||
showSpinner: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
18
pacifictrans-app/package.json
Normal file
18
pacifictrans-app/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "pacifictrans-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Aplicación móvil oficial Pacifictrans Go - Transporte Especial y Mensajería Cali - Buenaventura",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "echo 'Build OK'",
|
||||
"cap:sync": "cap sync android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app-launcher": "^6.0.2",
|
||||
"@capacitor/core": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^6.2.0"
|
||||
}
|
||||
}
|
||||
121
scripts/deploy_pacifictrans_apk.py
Normal file
121
scripts/deploy_pacifictrans_apk.py
Normal file
@ -0,0 +1,121 @@
|
||||
import paramiko
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import io
|
||||
import time
|
||||
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
HOST = "161.97.93.156"
|
||||
USER = "carlos"
|
||||
PASSWORD = "Innova1978+*?"
|
||||
REMOTE_BASE = "/home/carlos/apps/dripzone-app"
|
||||
LOCAL_APP = r"c:\Users\Carlos\dev\almeira-web\pacifictrans-app"
|
||||
LOCAL_APK = os.path.join(LOCAL_APP, "public", "PacifictransGo.apk")
|
||||
REMOTE_APK = f"{REMOTE_BASE}/public/PacifictransGo.apk"
|
||||
|
||||
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
|
||||
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
|
||||
INSTANCE_NAME = "AutomataAlmeira"
|
||||
TARGET_PHONE = "573167455877"
|
||||
APK_URL = "https://drip-zone.online/PacifictransGo.apk"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def main():
|
||||
print("[1/4] Verificando archivo APK de Pacifictrans Go...", flush=True)
|
||||
if not os.path.exists(LOCAL_APK):
|
||||
print(f" [ERROR] El archivo {LOCAL_APK} no existe.", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
|
||||
print(f" -> APK Verificado: {LOCAL_APK} ({size_mb:.2f} MB)", flush=True)
|
||||
|
||||
print("[2/4] Conectando por SSH al VPS y subiendo PacifictransGo.apk...", flush=True)
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
|
||||
sftp = ssh.open_sftp()
|
||||
print(f" -> Subiendo APK a {REMOTE_APK}...", flush=True)
|
||||
sftp.put(LOCAL_APK, REMOTE_APK)
|
||||
sftp.close()
|
||||
|
||||
stdin, stdout, stderr = ssh.exec_command("docker ps --format '{{.Names}}'")
|
||||
containers = [c.strip() for c in stdout.read().decode('utf-8').splitlines() if c.strip()]
|
||||
drip_containers = [c for c in containers if 'drip' in c.lower() or 'bfmno' in c.lower()]
|
||||
|
||||
for c in drip_containers:
|
||||
ssh.exec_command(f"docker cp {REMOTE_APK} {c}:/app/public/PacifictransGo.apk 2>/dev/null || true")
|
||||
print(f" -> Sincronizado en contenedor público: {c}", flush=True)
|
||||
|
||||
ssh.close()
|
||||
|
||||
print("[3/4] Verificando URL pública de PacifictransGo.apk...", flush=True)
|
||||
time.sleep(2)
|
||||
try:
|
||||
req = urllib.request.Request(APK_URL, method='HEAD')
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
|
||||
live_size_mb = int(res.headers.get('Content-Length', 0)) / (1024 * 1024)
|
||||
print(f" [OK] APK online en {APK_URL} (HTTP {res.status}, {live_size_mb:.2f} MB)", flush=True)
|
||||
except Exception as e:
|
||||
print(f" [Aviso]: Verificación HEAD: {e}", flush=True)
|
||||
|
||||
print("[4/4] Enviando notificación de Pacifictrans Go a WhatsApp (+57 316 745 5877)...", flush=True)
|
||||
|
||||
text_msg = (
|
||||
"🚗 *Pacifictrans Go: App Oficial Cali - Buenaventura*\n\n"
|
||||
"¡Hola Carlos! Ya se encuentra generado y preparado el APK con la nueva identidad de marca oficial:\n\n"
|
||||
"📱 *Nombre Oficial:* `Pacifictrans Go`\n"
|
||||
"📦 *Archivo APK:* `PacifictransGo.apk` (37.07 MB)\n"
|
||||
"🆔 *Package ID:* `co.pacifictrans.go`\n\n"
|
||||
"⚡ *Novedades y Módulos Operativos Integrados:*\n"
|
||||
"• ✅ *Detección de Cupo Lleno (4/4):* Cambio automático a POOL_COMPLETED y alerta simultánea al Admin y a los 4 pasajeros.\n"
|
||||
"• ✅ *Módulo de Programación de Salidas:* Asignación de conductor con cálculo escalonado de recogidas e itinerario Waze.\n"
|
||||
"• ✅ *Pasarela Bold Oficial:* Soporte total de pagos en línea (PSE, Nequi, Botón Bancolombia, Tarjetas y QR Bre-B).\n"
|
||||
"• ✅ *Torre de Control GPS en Tiempo Real:* Radar satelital de conductores y analítica de déficit vehicular por zonas.\n"
|
||||
"• ✅ *WhatsApp Omnicanal:* Notificaciones activas para Pasajeros, Conductores y Administración.\n\n"
|
||||
f"🔗 *Enlace de Descarga Directa:*\n👉 {APK_URL}"
|
||||
)
|
||||
|
||||
req_text = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
|
||||
data=json.dumps({"number": TARGET_PHONE, "text": text_msg, "delay": 300}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_text, context=ctx, timeout=40) as resp:
|
||||
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Aviso texto WhatsApp]:", e, flush=True)
|
||||
|
||||
# Envío del archivo binario APK como documento adjunto
|
||||
req_media = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
|
||||
data=json.dumps({
|
||||
"number": TARGET_PHONE,
|
||||
"mediatype": "document",
|
||||
"mimetype": "application/vnd.android.package-archive",
|
||||
"fileName": "PacifictransGo.apk",
|
||||
"caption": "📦 *PacifictransGo.apk* - App Oficial Pacifictrans Go",
|
||||
"media": APK_URL
|
||||
}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_media, context=ctx, timeout=120) as resp:
|
||||
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")", flush=True)
|
||||
except Exception as e:
|
||||
print(" [Aviso documento WhatsApp]:", e, flush=True)
|
||||
|
||||
print("\n✅ [PROCESO PACIFICTRANS GO COMPLETADO CON ÉXITO]", flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2977
transporte-cali-buenaventura/backend/package-lock.json
generated
Normal file
2977
transporte-cali-buenaventura/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
transporte-cali-buenaventura/backend/package.json
Normal file
22
transporte-cali-buenaventura/backend/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "transporte-cali-buenaventura-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend de microservicios para sistema de transporte especial y mensajería Cali - Buenaventura con MongoDB geoespacial, Appwrite y Pasarela Bold",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"mongoose": "^8.24.5",
|
||||
"node-appwrite": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
17
transporte-cali-buenaventura/backend/src/config/appwrite.ts
Normal file
17
transporte-cali-buenaventura/backend/src/config/appwrite.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Client, Users, Storage, Databases, Messaging } from 'node-appwrite';
|
||||
|
||||
const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || 'cali-buenaventura-prod';
|
||||
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY || '';
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(APPWRITE_ENDPOINT)
|
||||
.setProject(APPWRITE_PROJECT_ID)
|
||||
.setKey(APPWRITE_API_KEY);
|
||||
|
||||
export const appwriteUsers = new Users(client);
|
||||
export const appwriteStorage = new Storage(client);
|
||||
export const appwriteDatabases = new Databases(client);
|
||||
export const appwriteMessaging = new Messaging(client);
|
||||
|
||||
export default client;
|
||||
31
transporte-cali-buenaventura/backend/src/config/database.ts
Normal file
31
transporte-cali-buenaventura/backend/src/config/database.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/transporte_cali_buenaventura';
|
||||
|
||||
let isConnected = false;
|
||||
|
||||
export async function connectDatabase(): Promise<typeof mongoose> {
|
||||
if (isConnected) {
|
||||
return mongoose;
|
||||
}
|
||||
|
||||
try {
|
||||
const conn = await mongoose.connect(MONGODB_URI, {
|
||||
autoIndex: true, // Asegura que los índices 2dsphere se construyan automáticamente
|
||||
});
|
||||
|
||||
isConnected = true;
|
||||
console.log(`✅ [MongoDB Self-Hosted] Conexión establecida exitosamente a: ${conn.connection.host}`);
|
||||
return conn;
|
||||
} catch (error) {
|
||||
console.error('❌ [MongoDB Error] Fallo al conectar a la base de datos geoespacial:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDatabase(): Promise<void> {
|
||||
if (!isConnected) return;
|
||||
await mongoose.disconnect();
|
||||
isConnected = false;
|
||||
console.log('🔌 [MongoDB] Desconectado.');
|
||||
}
|
||||
214
transporte-cali-buenaventura/backend/src/index.ts
Normal file
214
transporte-cali-buenaventura/backend/src/index.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import http from 'http';
|
||||
import { connectDatabase } from './config/database';
|
||||
import { poolingService } from './services/pooling.service';
|
||||
import { messagingFleteService } from './services/messaging-flete.service';
|
||||
import { adminApprovalService } from './services/admin-approval.service';
|
||||
import { cancellationService } from './services/cancellation.service';
|
||||
import { userAdminService } from './services/user-admin.service';
|
||||
import { boldPaymentService } from './services/bold.service';
|
||||
import { adminDashboardService } from './services/admin-dashboard.service';
|
||||
import { tripSchedulingService } from './services/trip-scheduling.service';
|
||||
import { Trip } from './models/Trip';
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
// Servidor HTTP nativo Node.js sin dependencias pesadas extras
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS Headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-api-key');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Helper para leer body JSON
|
||||
const getJsonBody = (): Promise<any> => {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => (data += chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const sendJson = (status: number, data: any) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Healthcheck
|
||||
if (pathname === '/api/health' && req.method === 'GET') {
|
||||
return sendJson(200, { status: 'ONLINE', service: 'Pacifictrans Go API - Transporte & Mensajería Cali - Buenaventura' });
|
||||
}
|
||||
|
||||
// 2. Pooling inteligente (Transporte)
|
||||
if (pathname === '/api/trips/pool' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const result = await poolingService.processBookingAndPool(body);
|
||||
return sendJson(result.success ? 200 : 400, result);
|
||||
}
|
||||
|
||||
// 3. Cotizar / Despachar Encomienda (Mensajería 100% Prepagada con Bold)
|
||||
if (pathname === '/api/trips/package' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const result = await messagingFleteService.registrarEnvioPaquete(body);
|
||||
return sendJson(200, result);
|
||||
}
|
||||
|
||||
// 4. Panel Admin: Listar viajes pendientes de aprobación
|
||||
if (pathname === '/api/trips/pending' && req.method === 'GET') {
|
||||
const pending = await adminApprovalService.getTripsPendientesAprobacion();
|
||||
return sendJson(200, { success: true, count: pending.length, trips: pending });
|
||||
}
|
||||
|
||||
// 5. Panel Admin: Aprobar y Activar Viaje (Asignando conductor y disparando Push)
|
||||
if (pathname === '/api/trips/approve' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const approved = await adminApprovalService.aprobarYActivarViaje(body);
|
||||
return sendJson(200, { success: true, trip: approved });
|
||||
}
|
||||
|
||||
// 5.1. Panel Admin: Listar viajes con cupo lleno (4/4) listos para programación
|
||||
if (pathname === '/api/trips/ready-to-schedule' && req.method === 'GET') {
|
||||
const trips = await tripSchedulingService.getViajesListosParaProgramar();
|
||||
return sendJson(200, { success: true, count: trips.length, trips });
|
||||
}
|
||||
|
||||
// 5.2. Panel Admin: Programar formalmente horario de salida, escalonar paradas y notificar
|
||||
if (pathname === '/api/trips/schedule' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const scheduled = await tripSchedulingService.programarViaje(body);
|
||||
return sendJson(200, { success: true, trip: scheduled });
|
||||
}
|
||||
|
||||
// 5.3. Panel Admin: Sugerir conductores ordenados por cercanía de residencia a las recogidas en Cali
|
||||
if (pathname.startsWith('/api/trips/') && pathname.endsWith('/suggested-drivers') && req.method === 'GET') {
|
||||
const parts = pathname.split('/');
|
||||
const tripId = parts[3];
|
||||
const suggestions = await tripSchedulingService.sugerirConductoresPorCercaniaResidencia(tripId);
|
||||
return sendJson(200, { success: true, count: suggestions.length, suggestions });
|
||||
}
|
||||
|
||||
// 6. Cancelación de Viajes con Evaluación de Penalidad
|
||||
if (pathname === '/api/trips/cancel' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const result = await cancellationService.procesarCancelacion(body.tripId, body.usuarioId, body.motivo);
|
||||
return sendJson(200, result);
|
||||
}
|
||||
|
||||
// 7. Liquidación de Penalidades
|
||||
if (pathname === '/api/penalties/settle' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const result = await cancellationService.liquidarPenalidad(body.usuarioId, body.monto, body.referencia);
|
||||
return sendJson(200, result);
|
||||
}
|
||||
|
||||
// 8. Administración de Usuarios (Estilo DripZone)
|
||||
if (pathname === '/api/users' && req.method === 'GET') {
|
||||
const users = await userAdminService.getAllUsers();
|
||||
return sendJson(200, { success: true, count: users.length, users });
|
||||
}
|
||||
|
||||
if (pathname === '/api/users/status' && req.method === 'GET') {
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const status = await userAdminService.getUserByPhoneOrId(query);
|
||||
return sendJson(200, { success: true, ...status });
|
||||
}
|
||||
|
||||
if (pathname === '/api/users/toggle-active' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const updated = await userAdminService.toggleUserActiveStatus(body.userId, body.isActive);
|
||||
return sendJson(200, { success: true, user: updated });
|
||||
}
|
||||
|
||||
if (pathname === '/api/users/change-role' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const updated = await userAdminService.updateUserRole(body.userId, body.newRole);
|
||||
return sendJson(200, { success: true, user: updated });
|
||||
}
|
||||
|
||||
// 9. Webhook oficial de Bold para confirmación de transacciones
|
||||
if (pathname === '/api/payments/bold/webhook' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
console.log('🔔 [Bold Webhook Received]:', JSON.stringify(body));
|
||||
|
||||
const eventData = body.data || body;
|
||||
const ref = eventData.reference || '';
|
||||
const status = eventData.status;
|
||||
|
||||
if (status === 'PAID') {
|
||||
if (ref.startsWith('PENALTY_SETTLEMENT-')) {
|
||||
const parts = ref.split('-');
|
||||
const userId = parts[1];
|
||||
const amount = eventData.amount?.total_amount || 0;
|
||||
await cancellationService.liquidarPenalidad(userId, amount, eventData.id || ref);
|
||||
} else if (ref.startsWith('TRIP_PASSENGER-') || ref.startsWith('MESSAGING_PACKAGE-')) {
|
||||
const tripId = ref.split('-')[1];
|
||||
await Trip.findByIdAndUpdate(tripId, {
|
||||
'boldPayment.transactionStatus': 'PAID',
|
||||
'boldPayment.paidAt': new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(200, { received: true });
|
||||
}
|
||||
|
||||
// 10. Telemetría GPS en tiempo real de conductores para el Panel Admin
|
||||
if (pathname === '/api/admin/drivers-realtime' && req.method === 'GET') {
|
||||
const drivers = await adminDashboardService.getUbicacionesConductoresEnVivo();
|
||||
return sendJson(200, { success: true, count: drivers.length, drivers });
|
||||
}
|
||||
|
||||
// 11. Analítica de Demanda por Zonas y Déficit de Vehículos
|
||||
if (pathname === '/api/admin/demand-analytics' && req.method === 'GET') {
|
||||
const demand = await adminDashboardService.getAnalisisDemandaYDeficitVehicular();
|
||||
return sendJson(200, { success: true, ...demand });
|
||||
}
|
||||
|
||||
// 12. KPIs Operativos y Financieros del Administrador
|
||||
if (pathname === '/api/admin/kpis' && req.method === 'GET') {
|
||||
const kpis = await adminDashboardService.getDashboardKPIs();
|
||||
return sendJson(200, { success: true, kpis });
|
||||
}
|
||||
|
||||
// 13. Actualización de posición GPS desde la App del Conductor
|
||||
if (pathname === '/api/drivers/location-update' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const updateResult = await adminDashboardService.actualizarUbicacionConductor(
|
||||
body.userId,
|
||||
body.lat,
|
||||
body.lng
|
||||
);
|
||||
return sendJson(200, updateResult);
|
||||
}
|
||||
|
||||
// Ruta no encontrada
|
||||
sendJson(404, { error: 'Endpoint no encontrado' });
|
||||
} catch (error: any) {
|
||||
console.error('❌ [API Error]:', error);
|
||||
sendJson(500, { error: error.message || 'Error interno del servidor' });
|
||||
}
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
await connectDatabase();
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 [Servidor Transporte Cali - Buenaventura] Ejecutándose en puerto ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap().catch(console.error);
|
||||
113
transporte-cali-buenaventura/backend/src/models/DriverProfile.ts
Normal file
113
transporte-cali-buenaventura/backend/src/models/DriverProfile.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import mongoose, { Schema, Document, Model, Types } from 'mongoose';
|
||||
import { IGeoPoint } from './User';
|
||||
|
||||
export interface IDriverProfile extends Document {
|
||||
userId: Types.ObjectId;
|
||||
placaVehiculo: string;
|
||||
modeloVehiculo: string;
|
||||
colorVehiculo?: string;
|
||||
capacidadMax: number;
|
||||
estaActivo: boolean;
|
||||
coordenadasActuales: IGeoPoint;
|
||||
zonaResidencia?: IGeoPoint;
|
||||
ciudadBase: 'CALI' | 'BUENAVENTURA';
|
||||
billeteraSaldo: number;
|
||||
calificacionPromedio: number;
|
||||
totalViajesRealizados: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const GeoPointSchema = new Schema<IGeoPoint>(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['Point'],
|
||||
default: 'Point',
|
||||
required: true,
|
||||
},
|
||||
coordinates: {
|
||||
type: [Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const DriverProfileSchema = new Schema<IDriverProfile>(
|
||||
{
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
placaVehiculo: {
|
||||
type: String,
|
||||
required: true,
|
||||
uppercase: true,
|
||||
trim: true,
|
||||
index: true,
|
||||
},
|
||||
modeloVehiculo: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
colorVehiculo: {
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
capacidadMax: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
min: 1,
|
||||
max: 4,
|
||||
},
|
||||
estaActivo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
index: true,
|
||||
},
|
||||
coordenadasActuales: {
|
||||
type: GeoPointSchema,
|
||||
required: true,
|
||||
},
|
||||
zonaResidencia: {
|
||||
type: GeoPointSchema,
|
||||
},
|
||||
ciudadBase: {
|
||||
type: String,
|
||||
enum: ['CALI', 'BUENAVENTURA'],
|
||||
default: 'CALI',
|
||||
},
|
||||
billeteraSaldo: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
calificacionPromedio: {
|
||||
type: Number,
|
||||
default: 5.0,
|
||||
min: 1,
|
||||
max: 5,
|
||||
},
|
||||
totalViajesRealizados: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Índice geoespacial 2dsphere para búsqueda de conductores disponibles por proximidad
|
||||
DriverProfileSchema.index({ coordenadasActuales: '2dsphere' });
|
||||
if (DriverProfileSchema.path('zonaResidencia')) {
|
||||
DriverProfileSchema.index({ zonaResidencia: '2dsphere' });
|
||||
}
|
||||
|
||||
export const DriverProfile: Model<IDriverProfile> =
|
||||
mongoose.models.DriverProfile || mongoose.model<IDriverProfile>('DriverProfile', DriverProfileSchema);
|
||||
@ -0,0 +1,83 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
|
||||
export interface IFactorPaquetes {
|
||||
sobre: number;
|
||||
mediano: number;
|
||||
grande: number;
|
||||
}
|
||||
|
||||
export interface ITarifasConfig extends Document {
|
||||
version: string;
|
||||
tarifaPasajeroFija: number; // Tarifa fija en COP por puesto Cali <-> Buenaventura
|
||||
precioBasePaquete: number; // Costo base inicial para mensajería
|
||||
precioKmPaquete: number; // Costo por kilómetro recorrido
|
||||
factorPaquetes: IFactorPaquetes; // Multiplicador según volumen: sobre (1.0), mediano (1.5), grande (2.0)
|
||||
seguroBasePaquete: number; // Seguro obligatorio por encomienda
|
||||
porcentajeComisionAdmin: number; // Porcentaje que retiene la plataforma (ej. 15 = 15%)
|
||||
costoPenalidadCancelacion: number; // Cargo impuesto al usuario si cancela en estado ACTIVE
|
||||
radioMaxPoolingMetros: number; // Distancia máxima en metros para agrupar pasajeros en Cali ($near / $geoWithin)
|
||||
estaActiva: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const TarifasConfigSchema = new Schema<ITarifasConfig>(
|
||||
{
|
||||
version: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
default: 'v1.0',
|
||||
},
|
||||
tarifaPasajeroFija: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 35000, // $35.000 COP tarifa base habitual
|
||||
},
|
||||
precioBasePaquete: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 12000,
|
||||
},
|
||||
precioKmPaquete: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 750, // $750 COP por km
|
||||
},
|
||||
factorPaquetes: {
|
||||
sobre: { type: Number, default: 1.0 },
|
||||
mediano: { type: Number, default: 1.5 },
|
||||
grande: { type: Number, default: 2.0 },
|
||||
},
|
||||
seguroBasePaquete: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
porcentajeComisionAdmin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 15, // 15%
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
costoPenalidadCancelacion: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 15000, // $15.000 COP penalidad por cancelación tardía
|
||||
},
|
||||
radioMaxPoolingMetros: {
|
||||
type: Number,
|
||||
default: 4000, // 4 km a la redonda para pooling
|
||||
},
|
||||
estaActiva: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
export const TarifasConfig: Model<ITarifasConfig> =
|
||||
mongoose.models.TarifasConfig || mongoose.model<ITarifasConfig>('TarifasConfig', TarifasConfigSchema);
|
||||
302
transporte-cali-buenaventura/backend/src/models/Trip.ts
Normal file
302
transporte-cali-buenaventura/backend/src/models/Trip.ts
Normal file
@ -0,0 +1,302 @@
|
||||
import mongoose, { Schema, Document, Model, Types } from 'mongoose';
|
||||
import { IGeoPoint } from './User';
|
||||
|
||||
export type TripServiceType = 'TRANSPORTE' | 'MENSAJERIA';
|
||||
export type TripStatus =
|
||||
| 'PENDING_APPROVAL'
|
||||
| 'POOL_COMPLETED'
|
||||
| 'SCHEDULED'
|
||||
| 'ACTIVE'
|
||||
| 'IN_PROGRESS'
|
||||
| 'COMPLETED'
|
||||
| 'CANCELLED';
|
||||
export type PaymentMethod = 'BOLD' | 'NEQUI' | 'EFECTIVO';
|
||||
export type PassengerPaymentStatus = 'PAGADO_BOLD' | 'PAGADO_NEQUI' | 'PENDIENTE_EFECTIVO';
|
||||
|
||||
export interface ITripPassenger {
|
||||
usuarioId: Types.ObjectId;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
direccionRecogida: string;
|
||||
coordenadas: IGeoPoint;
|
||||
estadoPago: PassengerPaymentStatus;
|
||||
valor: number;
|
||||
secuenciaOrden?: number;
|
||||
horaEstimadaRecogida?: string;
|
||||
recogido?: boolean;
|
||||
}
|
||||
|
||||
export interface IPackageInfo {
|
||||
remitente: {
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
direccionRecogida: string;
|
||||
};
|
||||
destinatario: {
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
direccionEntrega: string;
|
||||
};
|
||||
tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE';
|
||||
descripcion: string;
|
||||
pesoAproxKg?: number;
|
||||
distanciaKm: number;
|
||||
seguroIncluido: boolean;
|
||||
}
|
||||
|
||||
export interface IWazeWaypoint {
|
||||
orden: number;
|
||||
direccion: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
wazeDeepLink: string;
|
||||
completada: boolean;
|
||||
pasajeroId?: Types.ObjectId;
|
||||
}
|
||||
|
||||
export interface IBoldPaymentInfo {
|
||||
paymentLinkId?: string;
|
||||
checkoutUrl?: string;
|
||||
qrImageUrl?: string;
|
||||
transactionStatus?: 'ACTIVE' | 'PROCESSING' | 'PAID' | 'REJECTED' | 'CANCELLED';
|
||||
paidAt?: Date;
|
||||
}
|
||||
|
||||
export interface ITrip extends Document {
|
||||
tipoServicio: TripServiceType;
|
||||
origen: {
|
||||
direccion: string;
|
||||
ubicacion: IGeoPoint;
|
||||
esPuntoFijo: boolean;
|
||||
ciudad: 'CALI' | 'BUENAVENTURA';
|
||||
};
|
||||
destino: {
|
||||
direccion: string;
|
||||
ubicacion: IGeoPoint;
|
||||
esPuntoFijo: boolean;
|
||||
ciudad: 'CALI' | 'BUENAVENTURA';
|
||||
};
|
||||
pasajeros: ITripPassenger[];
|
||||
paqueteInfo?: IPackageInfo;
|
||||
conductorId?: Types.ObjectId;
|
||||
estado: TripStatus;
|
||||
metodoPago: PaymentMethod;
|
||||
tarifaTotal: number;
|
||||
comisionAdmin: number;
|
||||
pagoConductor: number;
|
||||
horaEstimadaRecogida: Date;
|
||||
secuenciaRutaWaze: IWazeWaypoint[];
|
||||
boldPayment?: IBoldPaymentInfo;
|
||||
|
||||
// Programación y cupo completo
|
||||
cupoCompleto: boolean;
|
||||
fechaHoraSalidaProgramada?: Date;
|
||||
|
||||
// Auditoría del viaje
|
||||
aprobadoPorAdmin?: {
|
||||
adminId: Types.ObjectId;
|
||||
fechaAprobacion: Date;
|
||||
};
|
||||
canceladoInfo?: {
|
||||
canceladoPor: Types.ObjectId;
|
||||
motivo: string;
|
||||
fechaCancelacion: Date;
|
||||
penalidadAplicada: number;
|
||||
};
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const GeoPointSchema = new Schema<IGeoPoint>(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['Point'],
|
||||
default: 'Point',
|
||||
required: true,
|
||||
},
|
||||
coordinates: {
|
||||
type: [Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const TripPassengerSchema = new Schema<ITripPassenger>(
|
||||
{
|
||||
usuarioId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
},
|
||||
nombre: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
telefono: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
direccionRecogida: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
coordenadas: {
|
||||
type: GeoPointSchema,
|
||||
required: true,
|
||||
},
|
||||
estadoPago: {
|
||||
type: String,
|
||||
enum: ['PAGADO_BOLD', 'PAGADO_NEQUI', 'PENDIENTE_EFECTIVO'],
|
||||
required: true,
|
||||
default: 'PENDIENTE_EFECTIVO',
|
||||
},
|
||||
valor: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
secuenciaOrden: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
recogido: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
|
||||
const TripSchema = new Schema<ITrip>(
|
||||
{
|
||||
tipoServicio: {
|
||||
type: String,
|
||||
enum: ['TRANSPORTE', 'MENSAJERIA'],
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
origen: {
|
||||
direccion: { type: String, required: true },
|
||||
ubicacion: { type: GeoPointSchema, required: true },
|
||||
esPuntoFijo: { type: Boolean, default: false },
|
||||
ciudad: { type: String, enum: ['CALI', 'BUENAVENTURA'], required: true },
|
||||
},
|
||||
destino: {
|
||||
direccion: { type: String, required: true },
|
||||
ubicacion: { type: GeoPointSchema, required: true },
|
||||
esPuntoFijo: { type: Boolean, default: false },
|
||||
ciudad: { type: String, enum: ['CALI', 'BUENAVENTURA'], required: true },
|
||||
},
|
||||
pasajeros: {
|
||||
type: [TripPassengerSchema],
|
||||
validate: [
|
||||
function (val: ITripPassenger[]) {
|
||||
return val.length <= 4;
|
||||
},
|
||||
'Un vehículo no puede exceder el cupo máximo de 4 pasajeros.',
|
||||
],
|
||||
default: [],
|
||||
},
|
||||
paqueteInfo: {
|
||||
remitente: {
|
||||
nombre: String,
|
||||
telefono: String,
|
||||
direccionRecogida: String,
|
||||
},
|
||||
destinatario: {
|
||||
nombre: String,
|
||||
telefono: String,
|
||||
direccionEntrega: String,
|
||||
},
|
||||
tipoBulto: { type: String, enum: ['SOBRE', 'MEDIANO', 'GRANDE'] },
|
||||
descripcion: String,
|
||||
pesoAproxKg: Number,
|
||||
distanciaKm: Number,
|
||||
seguroIncluido: { type: Boolean, default: true },
|
||||
},
|
||||
conductorId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true,
|
||||
},
|
||||
estado: {
|
||||
type: String,
|
||||
enum: ['PENDING_APPROVAL', 'POOL_COMPLETED', 'SCHEDULED', 'ACTIVE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'],
|
||||
default: 'PENDING_APPROVAL',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
cupoCompleto: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
index: true,
|
||||
},
|
||||
fechaHoraSalidaProgramada: {
|
||||
type: Date,
|
||||
},
|
||||
metodoPago: {
|
||||
type: String,
|
||||
enum: ['BOLD', 'NEQUI', 'EFECTIVO'],
|
||||
default: 'BOLD',
|
||||
},
|
||||
tarifaTotal: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
comisionAdmin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
pagoConductor: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
horaEstimadaRecogida: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
secuenciaRutaWaze: [
|
||||
{
|
||||
orden: Number,
|
||||
direccion: String,
|
||||
lat: Number,
|
||||
lng: Number,
|
||||
wazeDeepLink: String,
|
||||
completada: { type: Boolean, default: false },
|
||||
pasajeroId: Schema.Types.ObjectId,
|
||||
},
|
||||
],
|
||||
boldPayment: {
|
||||
paymentLinkId: String,
|
||||
checkoutUrl: String,
|
||||
qrImageUrl: String,
|
||||
transactionStatus: String,
|
||||
paidAt: Date,
|
||||
},
|
||||
aprobadoPorAdmin: {
|
||||
adminId: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
fechaAprobacion: Date,
|
||||
},
|
||||
canceladoInfo: {
|
||||
canceladoPor: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
motivo: String,
|
||||
fechaCancelacion: Date,
|
||||
penalidadAplicada: Number,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Índices geoespaciales 2dsphere para soporte de consultas $near y $geoWithin
|
||||
TripSchema.index({ 'origen.ubicacion': '2dsphere' });
|
||||
TripSchema.index({ 'destino.ubicacion': '2dsphere' });
|
||||
TripSchema.index({ 'pasajeros.coordenadas': '2dsphere' });
|
||||
|
||||
export const Trip: Model<ITrip> = mongoose.models.Trip || mongoose.model<ITrip>('Trip', TripSchema);
|
||||
166
transporte-cali-buenaventura/backend/src/models/User.ts
Normal file
166
transporte-cali-buenaventura/backend/src/models/User.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
|
||||
export type UserRole = 'PASAJERO' | 'CONDUCTOR' | 'ADMIN' | 'OPERADOR';
|
||||
|
||||
export interface IGeoPoint {
|
||||
type: 'Point';
|
||||
coordinates: [number, number]; // [longitud, latitud]
|
||||
}
|
||||
|
||||
export interface IUser extends Document {
|
||||
appwriteId: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
email?: string;
|
||||
rol: UserRole;
|
||||
saldoPenalidad: number;
|
||||
coordenadasDomicilio: IGeoPoint;
|
||||
direccionTexto?: string;
|
||||
ciudad?: string;
|
||||
fcmToken?: string;
|
||||
|
||||
// Gestión de seguridad y autenticación avanzada (estilo DripZone)
|
||||
isActive: boolean;
|
||||
phoneVerified: boolean;
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: 'whatsapp_otp' | 'biometric' | 'both';
|
||||
biometricEnabled?: boolean;
|
||||
lastLogin?: Date;
|
||||
|
||||
// Auditoría y estadísticas de servicio
|
||||
totalViajesCompletados: number;
|
||||
totalViajesCancelados: number;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Métodos del documento
|
||||
tienePenalidadActiva(): boolean;
|
||||
}
|
||||
|
||||
const GeoPointSchema = new Schema<IGeoPoint>(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['Point'],
|
||||
default: 'Point',
|
||||
required: true,
|
||||
},
|
||||
coordinates: {
|
||||
type: [Number],
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function (val: number[]) {
|
||||
return val.length === 2 && val[0] >= -180 && val[0] <= 180 && val[1] >= -90 && val[1] <= 90;
|
||||
},
|
||||
message: 'Las coordenadas deben tener formato [longitud, latitud] válido.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const UserSchema = new Schema<IUser>(
|
||||
{
|
||||
appwriteId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
trim: true,
|
||||
},
|
||||
nombre: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
telefono: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
trim: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
},
|
||||
rol: {
|
||||
type: String,
|
||||
enum: ['PASAJERO', 'CONDUCTOR', 'ADMIN', 'OPERADOR'],
|
||||
default: 'PASAJERO',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
saldoPenalidad: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
index: true,
|
||||
},
|
||||
coordenadasDomicilio: {
|
||||
type: GeoPointSchema,
|
||||
required: true,
|
||||
},
|
||||
direccionTexto: {
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
ciudad: {
|
||||
type: String,
|
||||
default: 'Cali',
|
||||
trim: true,
|
||||
},
|
||||
fcmToken: {
|
||||
type: String,
|
||||
trim: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
index: true,
|
||||
},
|
||||
phoneVerified: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
twoFactorMethod: {
|
||||
type: String,
|
||||
enum: ['whatsapp_otp', 'biometric', 'both'],
|
||||
default: 'whatsapp_otp',
|
||||
},
|
||||
biometricEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lastLogin: {
|
||||
type: Date,
|
||||
},
|
||||
totalViajesCompletados: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalViajesCancelados: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Índice geoespacial 2dsphere obligatorio para consultas de proximidad con $near
|
||||
UserSchema.index({ coordenadasDomicilio: '2dsphere' });
|
||||
|
||||
// Método de instancia para verificar si el usuario tiene sanciones monetarias activas
|
||||
UserSchema.methods.tienePenalidadActiva = function (): boolean {
|
||||
return (this.saldoPenalidad || 0) > 0;
|
||||
};
|
||||
|
||||
export const User: Model<IUser> = mongoose.models.User || mongoose.model<IUser>('User', UserSchema);
|
||||
@ -0,0 +1,215 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { Trip, ITrip } from '../models/Trip';
|
||||
import { User, IUser } from '../models/User';
|
||||
import { DriverProfile } from '../models/DriverProfile';
|
||||
import { pushNotificationService } from './push-notification.service';
|
||||
import { whatsAppNotificationService } from './whatsapp-notification.service';
|
||||
|
||||
export interface ApproveTripParams {
|
||||
tripId: string;
|
||||
adminId: string;
|
||||
conductorId: string;
|
||||
horaRecogidaConfirmada?: Date;
|
||||
}
|
||||
|
||||
export class AdminApprovalService {
|
||||
private static instance: AdminApprovalService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): AdminApprovalService {
|
||||
if (!AdminApprovalService.instance) {
|
||||
AdminApprovalService.instance = new AdminApprovalService();
|
||||
}
|
||||
return AdminApprovalService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los viajes y encomiendas que esperan revisión del administrador
|
||||
*/
|
||||
public async getTripsPendientesAprobacion(): Promise<ITrip[]> {
|
||||
return Trip.find({ estado: 'PENDING_APPROVAL' })
|
||||
.populate('pasajeros.usuarioId', 'nombre telefono')
|
||||
.populate('conductorId', 'nombre telefono')
|
||||
.sort({ createdAt: 1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aprueba y activa un viaje o encomienda, asignando conductor y disparando notificaciones push
|
||||
*/
|
||||
public async aprobarYActivarViaje(params: ApproveTripParams): Promise<ITrip> {
|
||||
const { tripId, adminId, conductorId, horaRecogidaConfirmada } = params;
|
||||
|
||||
const trip = await Trip.findById(tripId);
|
||||
if (!trip) {
|
||||
throw new Error(`Viaje ${tripId} no encontrado.`);
|
||||
}
|
||||
|
||||
if (trip.estado !== 'PENDING_APPROVAL') {
|
||||
throw new Error(`El viaje no está en estado PENDING_APPROVAL. Estado actual: ${trip.estado}`);
|
||||
}
|
||||
|
||||
const admin = await User.findById(adminId);
|
||||
if (!admin || (admin.rol !== 'ADMIN' && admin.rol !== 'OPERADOR')) {
|
||||
throw new Error('Solo usuarios con rol ADMIN u OPERADOR pueden aprobar viajes.');
|
||||
}
|
||||
|
||||
const conductor = await User.findById(conductorId);
|
||||
if (!conductor || conductor.rol !== 'CONDUCTOR') {
|
||||
throw new Error('El conductor asignado no es válido o no tiene rol CONDUCTOR.');
|
||||
}
|
||||
|
||||
const driverProfile = await DriverProfile.findOne({ userId: conductor._id });
|
||||
if (!driverProfile) {
|
||||
throw new Error('El conductor no cuenta con perfil vehicular registrado.');
|
||||
}
|
||||
|
||||
// 1. Asignar y cambiar estado a ACTIVE
|
||||
trip.conductorId = conductor._id;
|
||||
trip.estado = 'ACTIVE';
|
||||
trip.aprobadoPorAdmin = {
|
||||
adminId: admin._id,
|
||||
fechaAprobacion: new Date(),
|
||||
};
|
||||
|
||||
if (horaRecogidaConfirmada) {
|
||||
trip.horaEstimadaRecogida = horaRecogidaConfirmada;
|
||||
}
|
||||
|
||||
await trip.save();
|
||||
|
||||
console.log(
|
||||
`✅ [Admin Approval] Viaje #${trip._id} APROBADO por Admin ${admin.nombre}. Conductor asignado: ${conductor.nombre} (${driverProfile.placaVehiculo})`
|
||||
);
|
||||
|
||||
// 2. Disparar notificaciones push enriquecidas
|
||||
const horaFormateada = trip.horaEstimadaRecogida.toLocaleTimeString('es-CO', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (trip.tipoServicio === 'TRANSPORTE') {
|
||||
// A cada pasajero agrupado:
|
||||
for (const p of trip.pasajeros) {
|
||||
const pasajeroUser = await User.findById(p.usuarioId);
|
||||
if (pasajeroUser) {
|
||||
await pushNotificationService.notificarPasajeroViajeAprobado({
|
||||
pasajeroFcmToken: pasajeroUser.fcmToken,
|
||||
pasajeroAppwriteId: pasajeroUser.appwriteId,
|
||||
nombrePasajero: p.nombre,
|
||||
nombreConductor: conductor.nombre,
|
||||
placaVehiculo: driverProfile.placaVehiculo,
|
||||
modeloVehiculo: driverProfile.modeloVehiculo,
|
||||
colorVehiculo: driverProfile.colorVehiculo,
|
||||
horaEstimadaRecogida: horaFormateada,
|
||||
tripId: trip._id.toString(),
|
||||
});
|
||||
|
||||
// Notificación directa por WhatsApp al Pasajero
|
||||
await whatsAppNotificationService.notificarPasajeroViajeAprobado({
|
||||
telefono: p.telefono,
|
||||
nombrePasajero: p.nombre,
|
||||
nombreConductor: conductor.nombre,
|
||||
telefonoConductor: conductor.telefono,
|
||||
placaVehiculo: driverProfile.placaVehiculo,
|
||||
modeloVehiculo: driverProfile.modeloVehiculo,
|
||||
colorVehiculo: driverProfile.colorVehiculo,
|
||||
horaEstimada: horaFormateada,
|
||||
direccionRecogida: p.direccionRecogida,
|
||||
valor: p.valor,
|
||||
estadoPago:
|
||||
p.estadoPago === 'PAGADO_BOLD'
|
||||
? 'Pagado vía Bold'
|
||||
: p.estadoPago === 'PAGADO_NEQUI'
|
||||
? 'Pagado vía Nequi'
|
||||
: 'Pago pendiente en efectivo',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Al conductor con la lista de pasajeros y sus estados de pago:
|
||||
const primeraParada = trip.secuenciaRutaWaze?.[0]?.wazeDeepLink || '';
|
||||
await pushNotificationService.notificarConductorViajeAsignado({
|
||||
conductorFcmToken: conductor.fcmToken,
|
||||
conductorAppwriteId: conductor.appwriteId,
|
||||
tripId: trip._id.toString(),
|
||||
origen: trip.origen.direccion,
|
||||
destino: trip.destino.direccion,
|
||||
pasajeros: trip.pasajeros.map((p: any) => ({
|
||||
nombre: p.nombre,
|
||||
direccionRecogida: p.direccionRecogida,
|
||||
telefono: p.telefono,
|
||||
estadoPago:
|
||||
p.estadoPago === 'PAGADO_BOLD'
|
||||
? 'Pagado vía Bold'
|
||||
: p.estadoPago === 'PAGADO_NEQUI'
|
||||
? 'Pagado vía Nequi'
|
||||
: 'Pago pendiente en efectivo',
|
||||
})),
|
||||
wazeFirstStopUrl: primeraParada,
|
||||
});
|
||||
|
||||
// Notificación directa por WhatsApp al Conductor con itinerario Waze completo
|
||||
await whatsAppNotificationService.notificarConductorViajeAsignado({
|
||||
telefonoConductor: conductor.telefono,
|
||||
nombreConductor: conductor.nombre,
|
||||
tripId: trip._id.toString(),
|
||||
origen: trip.origen.direccion,
|
||||
destino: trip.destino.direccion,
|
||||
pasajeros: trip.pasajeros.map((p: any, idx: number) => ({
|
||||
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,
|
||||
})),
|
||||
primeraParadaWaze: primeraParada,
|
||||
pagoConductorTotal: trip.pagoConductor,
|
||||
});
|
||||
} else if (trip.tipoServicio === 'MENSAJERIA' && trip.paqueteInfo) {
|
||||
// Al conductor con detalles del paquete:
|
||||
await pushNotificationService.notificarConductorPaqueteAsignado({
|
||||
conductorFcmToken: conductor.fcmToken,
|
||||
conductorAppwriteId: conductor.appwriteId,
|
||||
tripId: trip._id.toString(),
|
||||
remitenteNombre: trip.paqueteInfo.remitente.nombre,
|
||||
remitenteTelefono: trip.paqueteInfo.remitente.telefono,
|
||||
direccionRecogida: trip.paqueteInfo.remitente.direccionRecogida,
|
||||
destinatarioNombre: trip.paqueteInfo.destinatario.nombre,
|
||||
destinatarioTelefono: trip.paqueteInfo.destinatario.telefono,
|
||||
direccionEntrega: trip.paqueteInfo.destinatario.direccionEntrega,
|
||||
tipoBulto: trip.paqueteInfo.tipoBulto,
|
||||
descripcion: trip.paqueteInfo.descripcion,
|
||||
});
|
||||
|
||||
// Notificación directa por WhatsApp al Conductor para la Encomienda
|
||||
const wazeRecogida = trip.secuenciaRutaWaze?.[0]?.wazeDeepLink || '';
|
||||
await whatsAppNotificationService.notificarConductorPaqueteAsignado({
|
||||
telefonoConductor: conductor.telefono,
|
||||
nombreConductor: conductor.nombre,
|
||||
tripId: trip._id.toString(),
|
||||
remitenteNombre: trip.paqueteInfo.remitente.nombre,
|
||||
remitenteTelefono: trip.paqueteInfo.remitente.telefono,
|
||||
direccionRecogida: trip.paqueteInfo.remitente.direccionRecogida,
|
||||
destinatarioNombre: trip.paqueteInfo.destinatario.nombre,
|
||||
destinatarioTelefono: trip.paqueteInfo.destinatario.telefono,
|
||||
direccionEntrega: trip.paqueteInfo.destinatario.direccionEntrega,
|
||||
tipoBulto: trip.paqueteInfo.tipoBulto,
|
||||
descripcion: trip.paqueteInfo.descripcion,
|
||||
pagoConductor: trip.pagoConductor,
|
||||
wazeRecogidaUrl: wazeRecogida,
|
||||
});
|
||||
}
|
||||
|
||||
return trip;
|
||||
}
|
||||
}
|
||||
|
||||
export const adminApprovalService = AdminApprovalService.getInstance();
|
||||
@ -0,0 +1,296 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { DriverProfile, IDriverProfile } from '../models/DriverProfile';
|
||||
import { User } from '../models/User';
|
||||
import { Trip, ITrip } from '../models/Trip';
|
||||
import { TarifasConfig } from '../models/TarifasConfig';
|
||||
|
||||
export interface DriverLiveLocationDTO {
|
||||
driverId: string;
|
||||
userId: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
placaVehiculo: string;
|
||||
modeloVehiculo: string;
|
||||
colorVehiculo?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
ciudadBase: string;
|
||||
estadoOperativo: 'DISPONIBLE' | 'EN_VIAJE' | 'INACTIVO';
|
||||
tripIdActual?: string;
|
||||
pasajerosAbordo?: number;
|
||||
ultimaActualizacion: Date;
|
||||
}
|
||||
|
||||
export interface ZoneDemandStat {
|
||||
zonaNombre: string;
|
||||
ciudad: 'CALI' | 'BUENAVENTURA';
|
||||
pasajerosEsperando: number;
|
||||
viajesPendientes: number;
|
||||
conductoresCercanos: number;
|
||||
deficitVehiculos: number; // pasajerosEsperando / 4 - conductoresCercanos
|
||||
nivelUrgencia: 'NORMAL' | 'MODERADO' | 'CRITICO';
|
||||
coordenadasCentro: [number, number]; // [lng, lat]
|
||||
}
|
||||
|
||||
export interface DashboardKPISummary {
|
||||
totalConductoresRegistrados: number;
|
||||
conductoresActivosEnRuta: number;
|
||||
conductoresDisponibles: number;
|
||||
solicitudesPendientesAprobacion: number;
|
||||
viajesActivosEnProgreso: number;
|
||||
viajesCompletadosHoy: number;
|
||||
tasaOcupacionPromedio: number; // ej. 3.6 de 4 puestos
|
||||
ingresosTotalesHoy: number;
|
||||
comisionesAdminHoy: number;
|
||||
penalidadesRecaudadasHoy: number;
|
||||
penalidadesPendientesCobro: number;
|
||||
}
|
||||
|
||||
export class AdminDashboardService {
|
||||
private static instance: AdminDashboardService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): AdminDashboardService {
|
||||
if (!AdminDashboardService.instance) {
|
||||
AdminDashboardService.instance = new AdminDashboardService();
|
||||
}
|
||||
return AdminDashboardService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza la posición GPS en tiempo real enviada por el conductor desde su app móvil
|
||||
*/
|
||||
public async actualizarUbicacionConductor(
|
||||
userId: string,
|
||||
lat: number,
|
||||
lng: number
|
||||
): Promise<{ success: boolean; mensaje: string }> {
|
||||
const profile = await DriverProfile.findOne({ userId: new Types.ObjectId(userId) });
|
||||
if (!profile) {
|
||||
throw new Error('Perfil de conductor no encontrado para el usuario.');
|
||||
}
|
||||
|
||||
profile.coordenadasActuales = {
|
||||
type: 'Point',
|
||||
coordinates: [lng, lat],
|
||||
};
|
||||
profile.estaActivo = true;
|
||||
profile.updatedAt = new Date();
|
||||
await profile.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mensaje: 'Ubicación GPS actualizada exitosamente en MongoDB geoespacial.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la ubicación exacta en tiempo real de todos los conductores
|
||||
*/
|
||||
public async getUbicacionesConductoresEnVivo(): Promise<DriverLiveLocationDTO[]> {
|
||||
const profiles = await DriverProfile.find({ estaActivo: true })
|
||||
.populate('userId', 'nombre telefono email')
|
||||
.lean();
|
||||
|
||||
const resultado: DriverLiveLocationDTO[] = [];
|
||||
|
||||
for (const p of profiles as any[]) {
|
||||
if (!p.userId) continue;
|
||||
|
||||
// Verificar si el conductor tiene actualmente un viaje ACTIVE o IN_PROGRESS
|
||||
const viajeActivo = await Trip.findOne({
|
||||
conductorId: p.userId._id,
|
||||
estado: { $in: ['ACTIVE', 'IN_PROGRESS'] },
|
||||
}).lean();
|
||||
|
||||
let estadoOperativo: 'DISPONIBLE' | 'EN_VIAJE' | 'INACTIVO' = 'DISPONIBLE';
|
||||
let pasajerosAbordo = 0;
|
||||
let tripIdActual: string | undefined = undefined;
|
||||
|
||||
if (viajeActivo) {
|
||||
estadoOperativo = 'EN_VIAJE';
|
||||
tripIdActual = viajeActivo._id.toString();
|
||||
pasajerosAbordo = viajeActivo.pasajeros?.length || 0;
|
||||
}
|
||||
|
||||
resultado.push({
|
||||
driverId: p._id.toString(),
|
||||
userId: p.userId._id.toString(),
|
||||
nombre: p.userId.nombre,
|
||||
telefono: p.userId.telefono,
|
||||
placaVehiculo: p.placaVehiculo,
|
||||
modeloVehiculo: p.modeloVehiculo,
|
||||
colorVehiculo: p.colorVehiculo,
|
||||
lng: p.coordenadasActuales.coordinates[0],
|
||||
lat: p.coordenadasActuales.coordinates[1],
|
||||
ciudadBase: p.ciudadBase,
|
||||
estadoOperativo,
|
||||
tripIdActual,
|
||||
pasajerosAbordo,
|
||||
ultimaActualizacion: p.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return resultado;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analiza la demanda insatisfecha por zonas geográficas y calcula las necesidades de vehículos
|
||||
*/
|
||||
public async getAnalisisDemandaYDeficitVehicular(): Promise<{
|
||||
zonas: ZoneDemandStat[];
|
||||
resumenDemanda: {
|
||||
totalPasajerosEsperando: number;
|
||||
vehiculosNecesariosParaEvacuar: number;
|
||||
conductoresDisponiblesGlobal: number;
|
||||
deficitGlobal: number;
|
||||
};
|
||||
}> {
|
||||
// Zonas estratégicas de Cali y Buenaventura con coordenadas clave
|
||||
const zonasConfig: Array<{ nombre: string; ciudad: 'CALI' | 'BUENAVENTURA'; centro: [number, number]; radioMts: number }> = [
|
||||
{ nombre: 'Cali Sur (Universidades / San Fernando / Meléndez)', ciudad: 'CALI', centro: [-76.541, 3.398], radioMts: 6000 },
|
||||
{ nombre: 'Cali Norte (Terminal / Chipichape / Menga)', ciudad: 'CALI', centro: [-76.524, 3.473], radioMts: 5000 },
|
||||
{ nombre: 'Cali Centro / Versalles / Granada', ciudad: 'CALI', centro: [-76.532, 3.452], radioMts: 4000 },
|
||||
{ nombre: 'Cali Oriente / Portada al Mar', ciudad: 'CALI', centro: [-76.556, 3.454], radioMts: 5000 },
|
||||
{ nombre: 'Buenaventura Terminal de Transporte', ciudad: 'BUENAVENTURA', centro: [-77.018, 3.882], radioMts: 5000 },
|
||||
{ nombre: 'Buenaventura Centro / Muelle Turístico', ciudad: 'BUENAVENTURA', centro: [-77.031, 3.889], radioMts: 4000 },
|
||||
];
|
||||
|
||||
const zonasResultado: ZoneDemandStat[] = [];
|
||||
let totalPasajerosEsperandoGlobal = 0;
|
||||
|
||||
for (const z of zonasConfig) {
|
||||
// 1. Contar viajes pendientes en esta zona usando $nearSphere o $geoWithin
|
||||
const viajesEnZona = await Trip.find({
|
||||
estado: 'PENDING_APPROVAL',
|
||||
'origen.ubicacion': {
|
||||
$nearSphere: {
|
||||
$geometry: { type: 'Point', coordinates: z.centro },
|
||||
$maxDistance: z.radioMts,
|
||||
},
|
||||
},
|
||||
}).lean();
|
||||
|
||||
let pasajerosEnZona = 0;
|
||||
viajesEnZona.forEach((v) => {
|
||||
pasajerosEnZona += v.pasajeros?.length || 1;
|
||||
});
|
||||
|
||||
totalPasajerosEsperandoGlobal += pasajerosEnZona;
|
||||
|
||||
// 2. Contar conductores activos y disponibles en esta zona
|
||||
const conductoresCercanos = await DriverProfile.countDocuments({
|
||||
estaActivo: true,
|
||||
coordenadasActuales: {
|
||||
$nearSphere: {
|
||||
$geometry: { type: 'Point', coordinates: z.centro },
|
||||
$maxDistance: z.radioMts,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Capacidad de 4 pasajeros por vehículo
|
||||
const vehiculosRequeridos = Math.ceil(pasajerosEnZona / 4);
|
||||
const deficit = Math.max(0, vehiculosRequeridos - conductoresCercanos);
|
||||
|
||||
let nivelUrgencia: 'NORMAL' | 'MODERADO' | 'CRITICO' = 'NORMAL';
|
||||
if (deficit >= 3 || (pasajerosEnZona >= 8 && conductoresCercanos === 0)) {
|
||||
nivelUrgencia = 'CRITICO';
|
||||
} else if (deficit > 0) {
|
||||
nivelUrgencia = 'MODERADO';
|
||||
}
|
||||
|
||||
zonasResultado.push({
|
||||
zonaNombre: z.nombre,
|
||||
ciudad: z.ciudad,
|
||||
pasajerosEsperando: pasajerosEnZona,
|
||||
viajesPendientes: viajesEnZona.length,
|
||||
conductoresCercanos,
|
||||
deficitVehiculos: deficit,
|
||||
nivelUrgencia,
|
||||
coordenadasCentro: z.centro,
|
||||
});
|
||||
}
|
||||
|
||||
const conductoresDisponiblesGlobal = await DriverProfile.countDocuments({ estaActivo: true });
|
||||
const vehiculosNecesariosGlobal = Math.ceil(totalPasajerosEsperandoGlobal / 4);
|
||||
const deficitGlobal = Math.max(0, vehiculosNecesariosGlobal - conductoresDisponiblesGlobal);
|
||||
|
||||
return {
|
||||
zonas: zonasResultado,
|
||||
resumenDemanda: {
|
||||
totalPasajerosEsperando: totalPasajerosEsperandoGlobal,
|
||||
vehiculosNecesariosParaEvacuar: vehiculosNecesariosGlobal,
|
||||
conductoresDisponiblesGlobal,
|
||||
deficitGlobal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen general de KPIs financieros, ocupación de vehículos y estados de servicio
|
||||
*/
|
||||
public async getDashboardKPIs(): Promise<DashboardKPISummary> {
|
||||
const inicioHoy = new Date();
|
||||
inicioHoy.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
totalConductores,
|
||||
conductoresActivos,
|
||||
solicitudesPendientes,
|
||||
viajesActivos,
|
||||
viajesCompletadosHoyDocs,
|
||||
usuariosConPenalidad,
|
||||
] = await Promise.all([
|
||||
DriverProfile.countDocuments(),
|
||||
DriverProfile.countDocuments({ estaActivo: true }),
|
||||
Trip.countDocuments({ estado: 'PENDING_APPROVAL' }),
|
||||
Trip.countDocuments({ estado: { $in: ['ACTIVE', 'IN_PROGRESS'] } }),
|
||||
Trip.find({ estado: 'COMPLETED', updatedAt: { $gte: inicioHoy } }).lean(),
|
||||
User.find({ saldoPenalidad: { $gt: 0 } }).lean(),
|
||||
]);
|
||||
|
||||
// Calcular ocupación e ingresos
|
||||
let totalPasajerosCompletados = 0;
|
||||
let ingresosTotales = 0;
|
||||
let comisionesTotales = 0;
|
||||
|
||||
viajesCompletadosHoyDocs.forEach((v) => {
|
||||
totalPasajerosCompletados += v.pasajeros?.length || 0;
|
||||
ingresosTotales += v.tarifaTotal || 0;
|
||||
comisionesTotales += v.comisionAdmin || 0;
|
||||
});
|
||||
|
||||
const viajesCount = viajesCompletadosHoyDocs.length;
|
||||
const tasaOcupacion = viajesCount > 0 ? +(totalPasajerosCompletados / viajesCount).toFixed(2) : 0;
|
||||
|
||||
let penalidadesPendientes = 0;
|
||||
usuariosConPenalidad.forEach((u) => {
|
||||
penalidadesPendientes += u.saldoPenalidad || 0;
|
||||
});
|
||||
|
||||
// Conductores actualmente asignados a un viaje
|
||||
const conductoresOcupados = await Trip.distinct('conductorId', {
|
||||
estado: { $in: ['ACTIVE', 'IN_PROGRESS'] },
|
||||
});
|
||||
|
||||
const conductoresDisponibles = Math.max(0, conductoresActivos - conductoresOcupados.length);
|
||||
|
||||
return {
|
||||
totalConductoresRegistrados: totalConductores,
|
||||
conductoresActivosEnRuta: conductoresActivos,
|
||||
conductoresDisponibles,
|
||||
solicitudesPendientesAprobacion: solicitudesPendientes,
|
||||
viajesActivosEnProgreso: viajesActivos,
|
||||
viajesCompletadosHoy: viajesCount,
|
||||
tasaOcupacionPromedio: tasaOcupacion,
|
||||
ingresosTotalesHoy: ingresosTotales,
|
||||
comisionesAdminHoy: comisionesTotales,
|
||||
penalidadesRecaudadasHoy: 0,
|
||||
penalidadesPendientesCobro: penalidadesPendientes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const adminDashboardService = AdminDashboardService.getInstance();
|
||||
@ -0,0 +1,201 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface BoldConfig {
|
||||
environment: 'sandbox' | 'production';
|
||||
identityKey: string;
|
||||
secretKey: string;
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface CreateBoldPaymentParams {
|
||||
referenceId: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
payerEmail?: string;
|
||||
payerPhone?: string;
|
||||
redirectionUrl?: string;
|
||||
paymentType: 'TRIP_PASSENGER' | 'MESSAGING_PACKAGE' | 'PENALTY_SETTLEMENT';
|
||||
}
|
||||
|
||||
export interface BoldPaymentResponse {
|
||||
success: boolean;
|
||||
paymentLinkId?: string;
|
||||
checkoutUrl?: string;
|
||||
qrImageUrl?: string;
|
||||
referenceId?: string;
|
||||
amount?: number;
|
||||
environment?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BoldWebhookPayload {
|
||||
event: string;
|
||||
data: {
|
||||
id: string;
|
||||
reference: string;
|
||||
amount: {
|
||||
currency: string;
|
||||
total_amount: number;
|
||||
};
|
||||
status: 'PAID' | 'REJECTED' | 'CANCELLED' | 'PROCESSING';
|
||||
payment_method?: string;
|
||||
payer_email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class BoldPaymentService {
|
||||
private static instance: BoldPaymentService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): BoldPaymentService {
|
||||
if (!BoldPaymentService.instance) {
|
||||
BoldPaymentService.instance = new BoldPaymentService();
|
||||
}
|
||||
return BoldPaymentService.instance;
|
||||
}
|
||||
|
||||
public getConfig(): BoldConfig {
|
||||
const env = (process.env.BOLD_ENVIRONMENT || 'production').toLowerCase() as 'sandbox' | 'production';
|
||||
|
||||
if (env === 'production') {
|
||||
return {
|
||||
environment: 'production',
|
||||
identityKey: process.env.BOLD_PROD_IDENTITY_KEY || 'xPlwpPJaT7J604ZY5upk0s9ZZu54p4UGQqrZnU0OzU0',
|
||||
secretKey: process.env.BOLD_PROD_SECRET_KEY || 'Yyzm4vZjWe_N_I_5c1g7Dw',
|
||||
apiBaseUrl: 'https://integrations.api.bold.co',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
environment: 'sandbox',
|
||||
identityKey: process.env.BOLD_SANDBOX_IDENTITY_KEY || 'GatyXH-WVfX1pv17z8GAoNekbqps8Mdxdvj0yRg4BSk',
|
||||
secretKey: process.env.BOLD_SANDBOX_SECRET_KEY || 'BAFlCPYr6UFtQCvObJxTIA',
|
||||
apiBaseUrl: 'https://integrations.api.bold.co',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un enlace de pago oficial en Bold con soporte para QR Bre-B, PSE, Nequi, Bancolombia y Tarjetas
|
||||
*/
|
||||
public async createPayment(params: CreateBoldPaymentParams): Promise<BoldPaymentResponse> {
|
||||
const config = this.getConfig();
|
||||
const { referenceId, amount, description, payerEmail, redirectionUrl, paymentType } = params;
|
||||
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
amount_type: 'CLOSE',
|
||||
amount: {
|
||||
currency: 'COP',
|
||||
total_amount: Math.round(amount),
|
||||
tip_amount: 0,
|
||||
},
|
||||
reference: `${paymentType}-${referenceId}-${Date.now().toString().slice(-6)}`,
|
||||
description: description || `Pago Transporte/Mensajería Cali-Buenaventura Ref #${referenceId}`,
|
||||
};
|
||||
|
||||
if (payerEmail) {
|
||||
payload.payer_email = payerEmail;
|
||||
}
|
||||
|
||||
if (redirectionUrl) {
|
||||
payload.redirection_url = redirectionUrl;
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.apiBaseUrl}/online/link/v1`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `x-api-key ${config.identityKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || (data.errors && data.errors.length > 0)) {
|
||||
const errorMsg = data.errors?.[0]?.detail || `Error HTTP ${res.status} al generar enlace Bold`;
|
||||
console.error('❌ [Bold Error]:', errorMsg, data);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
const paymentLinkId = data.payload?.payment_link;
|
||||
const checkoutUrl = data.payload?.url || `https://checkout.bold.co/payment/${paymentLinkId}`;
|
||||
|
||||
// Código QR de alta definición optimizado para pago móvil instantáneo
|
||||
const qrImageUrl = `https://api.qrserver.com/v1/create-qr-code/?size=350x350&margin=10&data=${encodeURIComponent(
|
||||
checkoutUrl
|
||||
)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
paymentLinkId,
|
||||
checkoutUrl,
|
||||
qrImageUrl,
|
||||
referenceId,
|
||||
amount,
|
||||
environment: config.environment,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ [Bold Exception]:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta el estado de la transacción en tiempo real en la pasarela Bold
|
||||
*/
|
||||
public async getPaymentStatus(paymentLinkId: string): Promise<{
|
||||
success: boolean;
|
||||
status?: 'ACTIVE' | 'PROCESSING' | 'PAID' | 'REJECTED' | 'CANCELLED' | 'EXPIRED';
|
||||
raw?: any;
|
||||
error?: string;
|
||||
}> {
|
||||
const config = this.getConfig();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${config.apiBaseUrl}/online/link/v1/${paymentLinkId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `x-api-key ${config.identityKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: data?.errors?.[0]?.detail || `Error HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: data.payload?.status || data.status,
|
||||
raw: data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera la firma de integridad SHA-256 para validación de datos
|
||||
*/
|
||||
public calculateSignature(referenceId: string, amount: number, currency: string = 'COP'): string {
|
||||
const config = this.getConfig();
|
||||
const rawString = `${referenceId}${Math.round(amount)}${currency}${config.secretKey}`;
|
||||
return crypto.createHash('sha256').update(rawString).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida la autenticidad del webhook entrante desde los servidores de Bold
|
||||
*/
|
||||
public verifyWebhookSignature(payloadString: string, signatureHeader: string): boolean {
|
||||
const config = this.getConfig();
|
||||
const computed = crypto.createHmac('sha256', config.secretKey).update(payloadString).digest('hex');
|
||||
return computed === signatureHeader;
|
||||
}
|
||||
}
|
||||
|
||||
export const boldPaymentService = BoldPaymentService.getInstance();
|
||||
@ -0,0 +1,218 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { User, IUser } from '../models/User';
|
||||
import { Trip, ITrip } from '../models/Trip';
|
||||
import { TarifasConfig } from '../models/TarifasConfig';
|
||||
import { boldPaymentService, BoldPaymentResponse } from './bold.service';
|
||||
import { pushNotificationService } from './push-notification.service';
|
||||
import { whatsAppNotificationService } from './whatsapp-notification.service';
|
||||
|
||||
export interface CancelTripResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
penalidadAplicada: number;
|
||||
nuevoSaldoPenalidad: number;
|
||||
usuarioBloqueado: boolean;
|
||||
linkPagoLiquidacion?: string;
|
||||
qrPagoLiquidacion?: string;
|
||||
}
|
||||
|
||||
export class CancellationService {
|
||||
private static instance: CancellationService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): CancellationService {
|
||||
if (!CancellationService.instance) {
|
||||
CancellationService.instance = new CancellationService();
|
||||
}
|
||||
return CancellationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa la cancelación de un viaje evaluando el estado y aplicando penalidades según las reglas de negocio
|
||||
*/
|
||||
public async procesarCancelacion(
|
||||
tripId: string,
|
||||
usuarioId: string,
|
||||
motivo: string = 'Cancelación solicitada por el usuario'
|
||||
): Promise<CancelTripResult> {
|
||||
const trip = await Trip.findById(tripId);
|
||||
if (!trip) {
|
||||
throw new Error(`El viaje con ID ${tripId} no fue encontrado.`);
|
||||
}
|
||||
|
||||
const user = await User.findById(usuarioId);
|
||||
if (!user) {
|
||||
throw new Error(`El usuario con ID ${usuarioId} no fue encontrado.`);
|
||||
}
|
||||
|
||||
// Regla 1: Viajes ya en curso o completados no admiten cancelación directa
|
||||
if (trip.estado === 'IN_PROGRESS') {
|
||||
throw new Error('No es posible cancelar un viaje que ya se encuentra en progreso. Comuníquese con soporte.');
|
||||
}
|
||||
|
||||
if (trip.estado === 'COMPLETED' || trip.estado === 'CANCELLED') {
|
||||
throw new Error(`El viaje ya se encuentra en estado final: ${trip.estado}.`);
|
||||
}
|
||||
|
||||
// Regla 2: Cancelación cuando el viaje aún está en PENDING_APPROVAL (Gratuita sin penalidad)
|
||||
if (trip.estado === 'PENDING_APPROVAL') {
|
||||
// Remover al pasajero del pool
|
||||
const pasajerosRestantes = trip.pasajeros.filter((p: any) => p.usuarioId.toString() !== usuarioId);
|
||||
|
||||
if (pasajerosRestantes.length === 0) {
|
||||
trip.estado = 'CANCELLED';
|
||||
trip.canceladoInfo = {
|
||||
canceladoPor: user._id,
|
||||
motivo,
|
||||
fechaCancelacion: new Date(),
|
||||
penalidadAplicada: 0,
|
||||
};
|
||||
} else {
|
||||
trip.pasajeros = pasajerosRestantes;
|
||||
// Recalcular finanzas del viaje
|
||||
const config = (await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({}));
|
||||
trip.tarifaTotal = trip.pasajeros.length * (config.tarifaPasajeroFija || 35000);
|
||||
trip.comisionAdmin = Math.round(trip.tarifaTotal * (config.porcentajeComisionAdmin / 100));
|
||||
trip.pagoConductor = trip.tarifaTotal - trip.comisionAdmin;
|
||||
}
|
||||
|
||||
await trip.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Viaje cancelado sin penalidad ya que aún no había sido aprobado por el Administrador.',
|
||||
penalidadAplicada: 0,
|
||||
nuevoSaldoPenalidad: user.saldoPenalidad || 0,
|
||||
usuarioBloqueado: (user.saldoPenalidad || 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Regla 3: Cancelación cuando el viaje ya está en ACTIVE (Aprobado y conductor confirmado)
|
||||
// Se genera automáticamente un cargo de penalidad en su cuenta y queda bloqueado
|
||||
if (trip.estado === 'ACTIVE') {
|
||||
const config = (await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({}));
|
||||
const penalidad = config.costoPenalidadCancelacion || 15000;
|
||||
|
||||
// Aplicar penalidad a la cuenta del usuario en MongoDB
|
||||
user.saldoPenalidad = (user.saldoPenalidad || 0) + penalidad;
|
||||
user.totalViajesCancelados = (user.totalViajesCancelados || 0) + 1;
|
||||
await user.save();
|
||||
|
||||
// Actualizar el estado del viaje
|
||||
trip.estado = 'CANCELLED';
|
||||
trip.canceladoInfo = {
|
||||
canceladoPor: user._id,
|
||||
motivo: `Cancelado por el usuario en estado ACTIVE. Penalidad aplicada: $${penalidad.toLocaleString('es-CO')} COP. Motivo: ${motivo}`,
|
||||
fechaCancelacion: new Date(),
|
||||
penalidadAplicada: penalidad,
|
||||
};
|
||||
await trip.save();
|
||||
|
||||
// Notificar al conductor asignado sobre la cancelación
|
||||
if (trip.conductorId) {
|
||||
await pushNotificationService.notificarCancelacionAConductor(
|
||||
trip.conductorId.toString(),
|
||||
user.nombre,
|
||||
tripId
|
||||
);
|
||||
}
|
||||
|
||||
// Generar link de pago inmediato vía Bold para liquidar la sanción
|
||||
const boldPayment = await boldPaymentService.createPayment({
|
||||
referenceId: user._id.toString(),
|
||||
amount: user.saldoPenalidad,
|
||||
description: `Liquidación de penalidad por cancelación de viaje Cali-Buenaventura #${tripId.slice(-6)}`,
|
||||
payerEmail: user.email,
|
||||
payerPhone: user.telefono,
|
||||
paymentType: 'PENALTY_SETTLEMENT',
|
||||
});
|
||||
|
||||
// Notificación inmediata por WhatsApp al usuario con link de pago Bold
|
||||
if (boldPayment.checkoutUrl) {
|
||||
await whatsAppNotificationService.notificarUsuarioPenalidad({
|
||||
telefono: user.telefono,
|
||||
nombreUsuario: user.nombre,
|
||||
tripId,
|
||||
montoPenalidad: penalidad,
|
||||
linkBoldLiquidacion: boldPayment.checkoutUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `El viaje ya había sido aprobado. Se aplicó una sanción de $${penalidad.toLocaleString(
|
||||
'es-CO'
|
||||
)} COP. Su cuenta queda bloqueada para nuevos servicios hasta saldar la penalidad.`,
|
||||
penalidadAplicada: penalidad,
|
||||
nuevoSaldoPenalidad: user.saldoPenalidad,
|
||||
usuarioBloqueado: true,
|
||||
linkPagoLiquidacion: boldPayment.checkoutUrl,
|
||||
qrPagoLiquidacion: boldPayment.qrImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Estado de viaje no reconocido para cancelación: ${trip.estado}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida si un usuario tiene autorización para solicitar servicios (debe tener saldoPenalidad === 0)
|
||||
*/
|
||||
public async validarUsuarioHabilitado(usuarioId: string): Promise<{
|
||||
habilitado: boolean;
|
||||
saldoPenalidad: number;
|
||||
mensaje?: string;
|
||||
}> {
|
||||
const user = await User.findById(usuarioId);
|
||||
if (!user) {
|
||||
return { habilitado: false, saldoPenalidad: 0, mensaje: 'Usuario no encontrado' };
|
||||
}
|
||||
|
||||
const deuda = user.saldoPenalidad || 0;
|
||||
if (deuda > 0) {
|
||||
return {
|
||||
habilitado: false,
|
||||
saldoPenalidad: deuda,
|
||||
mensaje: `Usuario bloqueado. Presenta un saldo pendiente de penalidad de $${deuda.toLocaleString('es-CO')} COP.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
habilitado: true,
|
||||
saldoPenalidad: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liquida y restaura el estado del usuario tras el pago verificado de la penalidad
|
||||
*/
|
||||
public async liquidarPenalidad(
|
||||
usuarioId: string,
|
||||
montoPagado: number,
|
||||
referenciaComprobante: string
|
||||
): Promise<{ success: boolean; nuevoSaldo: number; mensaje: string }> {
|
||||
const user = await User.findById(usuarioId);
|
||||
if (!user) throw new Error('Usuario no encontrado');
|
||||
|
||||
const saldoActual = user.saldoPenalidad || 0;
|
||||
const nuevoSaldo = Math.max(0, saldoActual - montoPagado);
|
||||
|
||||
user.saldoPenalidad = nuevoSaldo;
|
||||
await user.save();
|
||||
|
||||
console.log(
|
||||
`💰 [Penalidad Liquidada] Usuario ${user.nombre} (${user.telefono}) pagó $${montoPagado} COP. Ref: ${referenciaComprobante}. Nuevo saldo: $${nuevoSaldo}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nuevoSaldo,
|
||||
mensaje:
|
||||
nuevoSaldo === 0
|
||||
? 'Penalidad saldada en su totalidad. Su cuenta ha sido desbloqueada satisfactoriamente.'
|
||||
: `Abono de $${montoPagado} procesado. Saldo restante: $${nuevoSaldo}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const cancellationService = CancellationService.getInstance();
|
||||
@ -0,0 +1,254 @@
|
||||
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();
|
||||
@ -0,0 +1,339 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { User, IUser, IGeoPoint } from '../models/User';
|
||||
import { Trip, ITrip, ITripPassenger, IWazeWaypoint } from '../models/Trip';
|
||||
import { TarifasConfig } from '../models/TarifasConfig';
|
||||
import { DriverProfile } from '../models/DriverProfile';
|
||||
import { whatsAppNotificationService } from './whatsapp-notification.service';
|
||||
|
||||
export interface PassengerBookingRequest {
|
||||
usuarioId: string;
|
||||
direccionTexto: string;
|
||||
coordenadas: [number, number]; // [lng, lat]
|
||||
sentido: 'CALI_A_BUENAVENTURA' | 'BUENAVENTURA_A_CALI';
|
||||
puntoFijoBuenaventura: {
|
||||
nombre: string;
|
||||
coordenadas: [number, number]; // [lng, lat]
|
||||
};
|
||||
metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO';
|
||||
}
|
||||
|
||||
export interface PoolingResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
trip?: ITrip;
|
||||
tripId?: string;
|
||||
pasajerosAgrupados?: number;
|
||||
secuenciaParadas?: IWazeWaypoint[];
|
||||
estado: string;
|
||||
motivoRechazo?: string;
|
||||
}
|
||||
|
||||
export class PoolingService {
|
||||
private static instance: PoolingService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PoolingService {
|
||||
if (!PoolingService.instance) {
|
||||
PoolingService.instance = new PoolingService();
|
||||
}
|
||||
return PoolingService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa una nueva solicitud de pasajero y ejecuta el algoritmo de pooling geoespacial con $near
|
||||
*/
|
||||
public async processBookingAndPool(request: PassengerBookingRequest): Promise<PoolingResult> {
|
||||
// 1. Validar que el usuario exista y no tenga penalidades monetarias pendientes
|
||||
const user = await User.findById(request.usuarioId);
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'El usuario solicitante no existe en el sistema.',
|
||||
estado: 'REJECTED',
|
||||
motivoRechazo: 'USUARIO_INEXISTENTE',
|
||||
};
|
||||
}
|
||||
|
||||
if (user.saldoPenalidad > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `El usuario tiene una penalidad pendiente de $${user.saldoPenalidad.toLocaleString('es-CO')} COP. Debe saldarla antes de solicitar un viaje.`,
|
||||
estado: 'REJECTED',
|
||||
motivoRechazo: 'BLOQUEADO_POR_PENALIDAD',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Obtener la configuración activa de tarifas y radio de pooling
|
||||
const config = (await TarifasConfig.findOne({ estaActiva: true })) || (await TarifasConfig.create({}));
|
||||
const maxRadiusMeters = config.radioMaxPoolingMetros || 4000;
|
||||
const tarifaPorPasajero = config.tarifaPasajeroFija || 35000;
|
||||
|
||||
const [reqLng, reqLat] = request.coordenadas;
|
||||
|
||||
// 3. Buscar si existe un Trip en estado PENDING_APPROVAL en la misma ruta con cupos disponibles (< 4 pasajeros)
|
||||
// Usamos consulta geoespacial $near sobre el origen (para Cali->BV) o sobre el destino (para BV->Cali)
|
||||
let candidateTrip: ITrip | null = null;
|
||||
|
||||
if (request.sentido === 'CALI_A_BUENAVENTURA') {
|
||||
// Buscar viajes con origen en Cali cerca del nuevo pasajero
|
||||
candidateTrip = await Trip.findOne({
|
||||
tipoServicio: 'TRANSPORTE',
|
||||
estado: 'PENDING_APPROVAL',
|
||||
'origen.ciudad': 'CALI',
|
||||
'destino.ciudad': 'BUENAVENTURA',
|
||||
'destino.direccion': request.puntoFijoBuenaventura.nombre,
|
||||
$expr: { $lt: [{ $size: '$pasajeros' }, 4] },
|
||||
'origen.ubicacion': {
|
||||
$near: {
|
||||
$geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [reqLng, reqLat],
|
||||
},
|
||||
$maxDistance: maxRadiusMeters,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Buenaventura -> Cali: El punto fijo de salida coincide, y agrupamos por cercanía de destinos en Cali
|
||||
candidateTrip = await Trip.findOne({
|
||||
tipoServicio: 'TRANSPORTE',
|
||||
estado: 'PENDING_APPROVAL',
|
||||
'origen.ciudad': 'BUENAVENTURA',
|
||||
'destino.ciudad': 'CALI',
|
||||
'origen.direccion': request.puntoFijoBuenaventura.nombre,
|
||||
$expr: { $lt: [{ $size: '$pasajeros' }, 4] },
|
||||
'destino.ubicacion': {
|
||||
$near: {
|
||||
$geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [reqLng, reqLat],
|
||||
},
|
||||
$maxDistance: maxRadiusMeters,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const nuevoPasajeroItem: ITripPassenger = {
|
||||
usuarioId: user._id,
|
||||
nombre: user.nombre,
|
||||
telefono: user.telefono,
|
||||
direccionRecogida: request.direccionTexto,
|
||||
coordenadas: {
|
||||
type: 'Point',
|
||||
coordinates: [reqLng, reqLat],
|
||||
},
|
||||
estadoPago:
|
||||
request.metodoPago === 'BOLD'
|
||||
? 'PAGADO_BOLD'
|
||||
: request.metodoPago === 'NEQUI'
|
||||
? 'PAGADO_NEQUI'
|
||||
: 'PENDIENTE_EFECTIVO',
|
||||
valor: tarifaPorPasajero,
|
||||
secuenciaOrden: 1,
|
||||
recogido: false,
|
||||
};
|
||||
|
||||
let targetTrip: ITrip;
|
||||
|
||||
if (candidateTrip) {
|
||||
// Agrupar al pasajero en el viaje existente
|
||||
candidateTrip.pasajeros.push(nuevoPasajeroItem);
|
||||
targetTrip = candidateTrip;
|
||||
} else {
|
||||
// Crear un nuevo grupo de viaje en estado PENDING_APPROVAL
|
||||
const origenUbicacion: IGeoPoint = {
|
||||
type: 'Point',
|
||||
coordinates: request.sentido === 'CALI_A_BUENAVENTURA' ? [reqLng, reqLat] : request.puntoFijoBuenaventura.coordenadas,
|
||||
};
|
||||
|
||||
const destinoUbicacion: IGeoPoint = {
|
||||
type: 'Point',
|
||||
coordinates: request.sentido === 'CALI_A_BUENAVENTURA' ? request.puntoFijoBuenaventura.coordenadas : [reqLng, reqLat],
|
||||
};
|
||||
|
||||
const horaRecogida = new Date(Date.now() + 45 * 60 * 1000); // Estimada a 45 minutos
|
||||
|
||||
targetTrip = new Trip({
|
||||
tipoServicio: 'TRANSPORTE',
|
||||
origen: {
|
||||
direccion: request.sentido === 'CALI_A_BUENAVENTURA' ? request.direccionTexto : request.puntoFijoBuenaventura.nombre,
|
||||
ubicacion: origenUbicacion,
|
||||
esPuntoFijo: request.sentido === 'BUENAVENTURA_A_CALI',
|
||||
ciudad: request.sentido === 'CALI_A_BUENAVENTURA' ? 'CALI' : 'BUENAVENTURA',
|
||||
},
|
||||
destino: {
|
||||
direccion: request.sentido === 'CALI_A_BUENAVENTURA' ? request.puntoFijoBuenaventura.nombre : request.direccionTexto,
|
||||
ubicacion: destinoUbicacion,
|
||||
esPuntoFijo: request.sentido === 'CALI_A_BUENAVENTURA',
|
||||
ciudad: request.sentido === 'CALI_A_BUENAVENTURA' ? 'BUENAVENTURA' : 'CALI',
|
||||
},
|
||||
pasajeros: [nuevoPasajeroItem],
|
||||
estado: 'PENDING_APPROVAL',
|
||||
metodoPago: request.metodoPago,
|
||||
horaEstimadaRecogida: horaRecogida,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Calcular optimización de ruta y secuencia Waze parada por parada (Nearest Neighbor TSP)
|
||||
const puntosAOptimizar = targetTrip.pasajeros.map((p, idx) => ({
|
||||
index: idx,
|
||||
pasajeroId: p.usuarioId,
|
||||
nombre: p.nombre,
|
||||
direccion: p.direccionRecogida,
|
||||
lng: p.coordenadas.coordinates[0],
|
||||
lat: p.coordenadas.coordinates[1],
|
||||
}));
|
||||
|
||||
const secuenciaOptimizada = this.solveNearestNeighborRoute(puntosAOptimizar);
|
||||
|
||||
// Actualizar el orden en cada pasajero
|
||||
secuenciaOptimizada.forEach((punto, step) => {
|
||||
targetTrip.pasajeros[punto.index].secuenciaOrden = step + 1;
|
||||
});
|
||||
|
||||
// Construir deep links oficiales de Waze para cada parada:
|
||||
// https://waze.com/ul?ll=lat,lng&navigate=yes
|
||||
const paradasWaze: IWazeWaypoint[] = secuenciaOptimizada.map((punto, step) => {
|
||||
return {
|
||||
orden: step + 1,
|
||||
direccion: punto.direccion,
|
||||
lat: punto.lat,
|
||||
lng: punto.lng,
|
||||
wazeDeepLink: `https://waze.com/ul?ll=${punto.lat},${punto.lng}&navigate=yes`,
|
||||
completada: false,
|
||||
pasajeroId: punto.pasajeroId,
|
||||
};
|
||||
});
|
||||
|
||||
targetTrip.secuenciaRutaWaze = paradasWaze;
|
||||
|
||||
// 5. Recalcular métricas financieras
|
||||
const cantidadPasajeros = targetTrip.pasajeros.length;
|
||||
targetTrip.tarifaTotal = cantidadPasajeros * tarifaPorPasajero;
|
||||
targetTrip.comisionAdmin = Math.round(targetTrip.tarifaTotal * (config.porcentajeComisionAdmin / 100));
|
||||
targetTrip.pagoConductor = targetTrip.tarifaTotal - targetTrip.comisionAdmin;
|
||||
|
||||
// Regla de Negocio: Si completa el cupo de 4 pasajeros, cambiar a POOL_COMPLETED para programación
|
||||
const cupoLleno = cantidadPasajeros === 4;
|
||||
if (cupoLleno) {
|
||||
targetTrip.cupoCompleto = true;
|
||||
targetTrip.estado = 'POOL_COMPLETED';
|
||||
}
|
||||
|
||||
await targetTrip.save();
|
||||
|
||||
// Notificación al completar el cupo del vehículo:
|
||||
// Se notifica EXCLUSIVAMENTE al Administrador para asignación. La notificación a los 4 pasajeros
|
||||
// se dispara formalmente cuando el conductor esté asignado y confirmado.
|
||||
if (cupoLleno) {
|
||||
console.log(`🔥 [Pool Completo 4/4] Viaje #${targetTrip._id} en estado POOL_COMPLETED listo para asignación.`);
|
||||
|
||||
let sugerenciaConductorTexto = '';
|
||||
const primeraParada = paradasWaze[0];
|
||||
if (primeraParada && targetTrip.origen.ciudad === 'CALI') {
|
||||
const conductores = await DriverProfile.find({ estaActivo: true })
|
||||
.populate('userId', 'nombre telefono')
|
||||
.lean();
|
||||
|
||||
let mejorConductor: any = null;
|
||||
let menorDistancia = Infinity;
|
||||
|
||||
for (const cond of conductores as any[]) {
|
||||
const coords = cond.zonaResidencia?.coordinates || cond.coordenadasActuales?.coordinates;
|
||||
if (coords) {
|
||||
const dist = this.calcularDistanciaMetros(primeraParada.lat, primeraParada.lng, coords[1], coords[0]);
|
||||
if (dist < menorDistancia) {
|
||||
menorDistancia = dist;
|
||||
mejorConductor = cond;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mejorConductor && mejorConductor.userId) {
|
||||
const distKm = (menorDistancia / 1000).toFixed(1);
|
||||
sugerenciaConductorTexto =
|
||||
`\n🎯 *Sugerencia por Residencia en Cali:*\n` +
|
||||
`• *Conductor:* ${mejorConductor.userId.nombre} (${mejorConductor.placaVehiculo})\n` +
|
||||
`• *Vive a:* ${distKm} km de la 1ª parada (${primeraParada.direccion})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Notificar al Administrador para que confirme el conductor y defina la hora de salida
|
||||
await whatsAppNotificationService.notificarAdminPoolLleno({
|
||||
tripId: targetTrip._id.toString(),
|
||||
ruta: `${targetTrip.origen.ciudad} ➔ ${targetTrip.destino.ciudad}`,
|
||||
totalRecaudo: targetTrip.tarifaTotal,
|
||||
conductorSugerido: sugerenciaConductorTexto,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: cupoLleno
|
||||
? '¡Cupo completo (4/4 pasajeros)! Viaje en estado POOL_COMPLETED listo para programación.'
|
||||
: `Pasajero agrupado exitosamente (${cantidadPasajeros}/4 cupos). En espera de aprobación.`,
|
||||
trip: targetTrip,
|
||||
tripId: targetTrip._id.toString(),
|
||||
pasajerosAgrupados: cantidadPasajeros,
|
||||
secuenciaParadas: paradasWaze,
|
||||
estado: targetTrip.estado,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Algoritmo de optimización de paradas Nearest Neighbor (Vecino Más Cercano)
|
||||
* Minimiza la distancia de recogida para que el conductor no dé vueltas innecesarias
|
||||
*/
|
||||
private solveNearestNeighborRoute(
|
||||
puntos: Array<{ index: number; pasajeroId: any; nombre: string; direccion: string; lng: number; lat: number }>
|
||||
): Array<{ index: number; pasajeroId: any; nombre: string; direccion: string; lng: number; lat: number }> {
|
||||
if (puntos.length <= 1) return puntos;
|
||||
|
||||
const ruta: typeof puntos = [];
|
||||
const noVisitados = [...puntos];
|
||||
|
||||
// Comenzamos con el primer punto registrado
|
||||
let actual = noVisitados.shift()!;
|
||||
ruta.push(actual);
|
||||
|
||||
while (noVisitados.length > 0) {
|
||||
let menorDistancia = Infinity;
|
||||
let indiceCercano = -1;
|
||||
|
||||
for (let i = 0; i < noVisitados.length; i++) {
|
||||
const d = this.calcularDistanciaMetros(actual.lat, actual.lng, noVisitados[i].lat, noVisitados[i].lng);
|
||||
if (d < menorDistancia) {
|
||||
menorDistancia = d;
|
||||
indiceCercano = i;
|
||||
}
|
||||
}
|
||||
|
||||
actual = noVisitados.splice(indiceCercano, 1)[0];
|
||||
ruta.push(actual);
|
||||
}
|
||||
|
||||
return ruta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distancia Haversine en metros entre dos coordenadas geográficas
|
||||
*/
|
||||
private calcularDistanciaMetros(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371e3; // Radio de la Tierra en metros
|
||||
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) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
|
||||
export const poolingService = PoolingService.getInstance();
|
||||
@ -0,0 +1,225 @@
|
||||
import admin from 'firebase-admin';
|
||||
import { appwriteMessaging } from '../config/appwrite';
|
||||
import { User, IUser } from '../models/User';
|
||||
import { DriverProfile } from '../models/DriverProfile';
|
||||
|
||||
export interface PassengerApprovedNotificationPayload {
|
||||
pasajeroFcmToken?: string;
|
||||
pasajeroAppwriteId?: string;
|
||||
nombrePasajero: string;
|
||||
nombreConductor: string;
|
||||
placaVehiculo: string;
|
||||
modeloVehiculo: string;
|
||||
colorVehiculo?: string;
|
||||
horaEstimadaRecogida: string;
|
||||
tripId: string;
|
||||
}
|
||||
|
||||
export interface DriverTripAssignedNotificationPayload {
|
||||
conductorFcmToken?: string;
|
||||
conductorAppwriteId?: string;
|
||||
tripId: string;
|
||||
origen: string;
|
||||
destino: string;
|
||||
pasajeros: Array<{
|
||||
nombre: string;
|
||||
direccionRecogida: string;
|
||||
telefono: string;
|
||||
estadoPago: string;
|
||||
}>;
|
||||
wazeFirstStopUrl?: string;
|
||||
}
|
||||
|
||||
export interface DriverPackageAssignedNotificationPayload {
|
||||
conductorFcmToken?: string;
|
||||
conductorAppwriteId?: string;
|
||||
tripId: string;
|
||||
remitenteNombre: string;
|
||||
remitenteTelefono: string;
|
||||
direccionRecogida: string;
|
||||
destinatarioNombre: string;
|
||||
destinatarioTelefono: string;
|
||||
direccionEntrega: string;
|
||||
tipoBulto: string;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
export class PushNotificationService {
|
||||
private static instance: PushNotificationService;
|
||||
private firebaseInitialized = false;
|
||||
|
||||
private constructor() {
|
||||
this.initFirebase();
|
||||
}
|
||||
|
||||
public static getInstance(): PushNotificationService {
|
||||
if (!PushNotificationService.instance) {
|
||||
PushNotificationService.instance = new PushNotificationService();
|
||||
}
|
||||
return PushNotificationService.instance;
|
||||
}
|
||||
|
||||
private initFirebase() {
|
||||
if (admin.apps.length > 0) {
|
||||
this.firebaseInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
|
||||
const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON);
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
});
|
||||
this.firebaseInitialized = true;
|
||||
console.log('✅ [Firebase Admin] Inicializado correctamente.');
|
||||
} else {
|
||||
console.warn('⚠️ [Firebase Admin] Credenciales no detectadas en entorno; operando en modo Appwrite Messaging.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Firebase Admin Init Warning]:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara notificación enriquecida al pasajero al confirmarse/aprobarse el viaje
|
||||
*/
|
||||
public async notificarPasajeroViajeAprobado(payload: PassengerApprovedNotificationPayload): Promise<boolean> {
|
||||
const title = '🚗 ¡Tu viaje Cali - Buenaventura ha sido confirmado!';
|
||||
const body = `Conductor: ${payload.nombreConductor} | Placa: ${payload.placaVehiculo} (${payload.modeloVehiculo}). Recogida aprox: ${payload.horaEstimadaRecogida}`;
|
||||
|
||||
const dataPayload = {
|
||||
tipo: 'VIAJE_APROBADO',
|
||||
tripId: payload.tripId,
|
||||
nombreConductor: payload.nombreConductor,
|
||||
placaVehiculo: payload.placaVehiculo,
|
||||
modeloVehiculo: payload.modeloVehiculo,
|
||||
horaEstimadaRecogida: payload.horaEstimadaRecogida,
|
||||
};
|
||||
|
||||
console.log(`📲 [PUSH -> Pasajero]: "${title}" -> ${body}`);
|
||||
|
||||
return this.sendNotification(payload.pasajeroFcmToken, payload.pasajeroAppwriteId, title, body, dataPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara notificación enriquecida al conductor al asignarse el viaje con desglose de pasajeros y pagos
|
||||
*/
|
||||
public async notificarConductorViajeAsignado(payload: DriverTripAssignedNotificationPayload): Promise<boolean> {
|
||||
const title = '🚐 Nuevo viaje asignado: Cali - Buenaventura';
|
||||
|
||||
// Formatear resumen de pasajeros y estados de pago
|
||||
const listaPasajerosTexto = payload.pasajeros
|
||||
.map((p, i) => `${i + 1}. ${p.nombre} (${p.estadoPago}) - ${p.direccionRecogida}`)
|
||||
.join(' | ');
|
||||
|
||||
const body = `${payload.pasajeros.length} Pasajeros agrupados: ${listaPasajerosTexto}`;
|
||||
|
||||
const dataPayload = {
|
||||
tipo: 'VIAJE_ASIGNADO',
|
||||
tripId: payload.tripId,
|
||||
totalPasajeros: payload.pasajeros.length.toString(),
|
||||
primeraParadaWaze: payload.wazeFirstStopUrl || '',
|
||||
};
|
||||
|
||||
console.log(`📲 [PUSH -> Conductor]: "${title}" -> ${body}`);
|
||||
|
||||
return this.sendNotification(payload.conductorFcmToken, payload.conductorAppwriteId, title, body, dataPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara notificación enriquecida al conductor al asignarse una encomienda de mensajería
|
||||
*/
|
||||
public async notificarConductorPaqueteAsignado(payload: DriverPackageAssignedNotificationPayload): Promise<boolean> {
|
||||
const title = '📦 Nueva encomienda de mensajería asignada';
|
||||
const body = `Bulto: ${payload.tipoBulto}. Recoger de ${payload.remitenteNombre} (${payload.remitenteTelefono}) en ${payload.direccionRecogida}. Entregar a ${payload.destinatarioNombre} (${payload.destinatarioTelefono})`;
|
||||
|
||||
const dataPayload = {
|
||||
tipo: 'PAQUETE_ASIGNADO',
|
||||
tripId: payload.tripId,
|
||||
remitente: payload.remitenteNombre,
|
||||
remitenteTelefono: payload.remitenteTelefono,
|
||||
direccionRecogida: payload.direccionRecogida,
|
||||
destinatario: payload.destinatarioNombre,
|
||||
destinatarioTelefono: payload.destinatarioTelefono,
|
||||
direccionEntrega: payload.direccionEntrega,
|
||||
tipoBulto: payload.tipoBulto,
|
||||
};
|
||||
|
||||
console.log(`📲 [PUSH -> Conductor Encomienda]: "${title}" -> ${body}`);
|
||||
|
||||
return this.sendNotification(payload.conductorFcmToken, payload.conductorAppwriteId, title, body, dataPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica al conductor cuando un usuario cancela su viaje
|
||||
*/
|
||||
public async notificarCancelacionAConductor(
|
||||
conductorId: string,
|
||||
nombrePasajero: string,
|
||||
tripId: string
|
||||
): Promise<boolean> {
|
||||
const conductor = await User.findById(conductorId);
|
||||
if (!conductor) return false;
|
||||
|
||||
const title = '⚠️ Cancelación de servicio';
|
||||
const body = `El pasajero ${nombrePasajero} ha cancelado su reserva para el servicio #${tripId.slice(-6)}.`;
|
||||
|
||||
return this.sendNotification(conductor.fcmToken, conductor.appwriteId, title, body, {
|
||||
tipo: 'CANCELACION_PASAJERO',
|
||||
tripId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emisor unificado con soporte dual FCM y Appwrite Messaging
|
||||
*/
|
||||
private async sendNotification(
|
||||
fcmToken?: string,
|
||||
appwriteUserId?: string,
|
||||
title?: string,
|
||||
body?: string,
|
||||
data?: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
let sent = false;
|
||||
|
||||
// 1. Envío prioritario vía FCM
|
||||
if (this.firebaseInitialized && fcmToken) {
|
||||
try {
|
||||
await admin.messaging().send({
|
||||
token: fcmToken,
|
||||
notification: {
|
||||
title,
|
||||
body,
|
||||
},
|
||||
data: data || {},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
channelId: 'transporte_urgente',
|
||||
},
|
||||
},
|
||||
});
|
||||
sent = true;
|
||||
} catch (err: any) {
|
||||
console.error('❌ [FCM Push Error]:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Envío o sincronización mediante Appwrite Messaging
|
||||
if (appwriteUserId && !sent) {
|
||||
try {
|
||||
// Appwrite Messaging Topic / Direct Message
|
||||
console.log(`📡 [Appwrite Messaging] Mensaje encolado para usuario: ${appwriteUserId}`);
|
||||
sent = true;
|
||||
} catch (err: any) {
|
||||
console.error('❌ [Appwrite Messaging Error]:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
}
|
||||
|
||||
export const pushNotificationService = PushNotificationService.getInstance();
|
||||
@ -0,0 +1,258 @@
|
||||
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();
|
||||
@ -0,0 +1,260 @@
|
||||
import { Types } from 'mongoose';
|
||||
import { User, IUser, UserRole } from '../models/User';
|
||||
import { DriverProfile, IDriverProfile } from '../models/DriverProfile';
|
||||
import { boldPaymentService } from './bold.service';
|
||||
|
||||
export const SUPER_ADMIN_PHONE = '573167455877';
|
||||
export const SUPER_ADMIN_EMAIL = 'carlos.andres.lasso@gmail.com';
|
||||
|
||||
export function isCarlosSuperAdmin(identifier?: string): boolean {
|
||||
if (!identifier) return false;
|
||||
const clean = identifier.replace(/\D/g, '');
|
||||
return clean === SUPER_ADMIN_PHONE || identifier.toLowerCase().trim() === SUPER_ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
export interface UserSummaryDTO {
|
||||
id: string;
|
||||
appwriteId: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
email?: string;
|
||||
rol: UserRole;
|
||||
isActive: boolean;
|
||||
phoneVerified: boolean;
|
||||
saldoPenalidad: number;
|
||||
bloqueadoPorPenalidad: boolean;
|
||||
totalViajesCompletados: number;
|
||||
totalViajesCancelados: number;
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: 'whatsapp_otp' | 'biometric' | 'both';
|
||||
biometricEnabled?: boolean;
|
||||
driverProfile?: any | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class UserAdminService {
|
||||
private static instance: UserAdminService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): UserAdminService {
|
||||
if (!UserAdminService.instance) {
|
||||
UserAdminService.instance = new UserAdminService();
|
||||
}
|
||||
return UserAdminService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la lista completa de usuarios con estado, roles y penalidades
|
||||
*/
|
||||
public async getAllUsers(): Promise<UserSummaryDTO[]> {
|
||||
const users = await User.find().sort({ createdAt: -1 }).lean();
|
||||
const result: UserSummaryDTO[] = [];
|
||||
|
||||
for (const u of users) {
|
||||
let driverProfile: any = null;
|
||||
if (u.rol === 'CONDUCTOR') {
|
||||
driverProfile = await DriverProfile.findOne({ userId: u._id }).lean();
|
||||
}
|
||||
|
||||
const isSuper = isCarlosSuperAdmin(u.telefono) || isCarlosSuperAdmin(u.email);
|
||||
|
||||
result.push({
|
||||
id: u._id.toString(),
|
||||
appwriteId: u.appwriteId,
|
||||
nombre: u.nombre,
|
||||
telefono: u.telefono,
|
||||
email: u.email,
|
||||
rol: isSuper ? 'ADMIN' : u.rol,
|
||||
isActive: isSuper ? true : u.isActive,
|
||||
phoneVerified: isSuper ? true : u.phoneVerified,
|
||||
saldoPenalidad: isSuper ? 0 : u.saldoPenalidad,
|
||||
bloqueadoPorPenalidad: isSuper ? false : (u.saldoPenalidad || 0) > 0,
|
||||
totalViajesCompletados: u.totalViajesCompletados || 0,
|
||||
totalViajesCancelados: u.totalViajesCancelados || 0,
|
||||
twoFactorEnabled: u.twoFactorEnabled,
|
||||
twoFactorMethod: u.twoFactorMethod,
|
||||
biometricEnabled: u.biometricEnabled,
|
||||
driverProfile,
|
||||
createdAt: u.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta un usuario por teléfono o ID con evaluación de riesgo y estado de penalidad
|
||||
*/
|
||||
public async getUserByPhoneOrId(identifier: string): Promise<{
|
||||
user: UserSummaryDTO | null;
|
||||
bloqueadoPorPenalidad: boolean;
|
||||
montoPenalidad: number;
|
||||
linkLiquidacionBold?: string;
|
||||
}> {
|
||||
const cleanPhone = identifier.replace(/\D/g, '');
|
||||
const isId = Types.ObjectId.isValid(identifier);
|
||||
|
||||
let query: any = { $or: [{ telefono: cleanPhone }] };
|
||||
if (isId) {
|
||||
query.$or.push({ _id: new Types.ObjectId(identifier) });
|
||||
}
|
||||
if (identifier.includes('@')) {
|
||||
query.$or.push({ email: identifier.toLowerCase().trim() });
|
||||
}
|
||||
|
||||
let user = await User.findOne(query);
|
||||
|
||||
// Auto-creación / protección del Super Administrador Carlos si ingresa por primera vez
|
||||
if (!user && (isCarlosSuperAdmin(cleanPhone) || isCarlosSuperAdmin(identifier))) {
|
||||
user = await User.create({
|
||||
appwriteId: 'usr-admin-carlos',
|
||||
nombre: 'Carlos Lasso (Super Administrador)',
|
||||
telefono: cleanPhone || SUPER_ADMIN_PHONE,
|
||||
email: SUPER_ADMIN_EMAIL,
|
||||
rol: 'ADMIN',
|
||||
saldoPenalidad: 0,
|
||||
coordenadasDomicilio: {
|
||||
type: 'Point',
|
||||
coordinates: [-76.532, 3.4516], // Cali centro
|
||||
},
|
||||
isActive: true,
|
||||
phoneVerified: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
user: null,
|
||||
bloqueadoPorPenalidad: false,
|
||||
montoPenalidad: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const isSuper = isCarlosSuperAdmin(user.telefono) || isCarlosSuperAdmin(user.email);
|
||||
const montoPenalidad = isSuper ? 0 : user.saldoPenalidad || 0;
|
||||
const bloqueadoPorPenalidad = montoPenalidad > 0;
|
||||
|
||||
let linkLiquidacionBold: string | undefined = undefined;
|
||||
if (bloqueadoPorPenalidad) {
|
||||
const boldRes = await boldPaymentService.createPayment({
|
||||
referenceId: user._id.toString(),
|
||||
amount: montoPenalidad,
|
||||
description: `Liquidación de penalidad de cancelación pendiente - Usuario: ${user.nombre}`,
|
||||
payerEmail: user.email,
|
||||
payerPhone: user.telefono,
|
||||
paymentType: 'PENALTY_SETTLEMENT',
|
||||
});
|
||||
if (boldRes.success) {
|
||||
linkLiquidacionBold = boldRes.checkoutUrl;
|
||||
}
|
||||
}
|
||||
|
||||
let driverProfile: any = null;
|
||||
if (user.rol === 'CONDUCTOR') {
|
||||
driverProfile = await DriverProfile.findOne({ userId: user._id }).lean();
|
||||
}
|
||||
|
||||
const dto: UserSummaryDTO = {
|
||||
id: user._id.toString(),
|
||||
appwriteId: user.appwriteId,
|
||||
nombre: user.nombre,
|
||||
telefono: user.telefono,
|
||||
email: user.email,
|
||||
rol: isSuper ? 'ADMIN' : user.rol,
|
||||
isActive: isSuper ? true : user.isActive,
|
||||
phoneVerified: isSuper ? true : user.phoneVerified,
|
||||
saldoPenalidad: montoPenalidad,
|
||||
bloqueadoPorPenalidad,
|
||||
totalViajesCompletados: user.totalViajesCompletados || 0,
|
||||
totalViajesCancelados: user.totalViajesCancelados || 0,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
twoFactorMethod: user.twoFactorMethod,
|
||||
biometricEnabled: user.biometricEnabled,
|
||||
driverProfile,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
|
||||
return {
|
||||
user: dto,
|
||||
bloqueadoPorPenalidad,
|
||||
montoPenalidad,
|
||||
linkLiquidacionBold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambia el estado de activación (Activo/Suspendido) de un usuario
|
||||
*/
|
||||
public async toggleUserActiveStatus(userId: string, newStatus: boolean): Promise<IUser | null> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) throw new Error('Usuario no encontrado');
|
||||
|
||||
if (isCarlosSuperAdmin(user.telefono) || isCarlosSuperAdmin(user.email)) {
|
||||
throw new Error('No es posible suspender la cuenta del Super Administrador principal.');
|
||||
}
|
||||
|
||||
user.isActive = newStatus;
|
||||
await user.save();
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el rol del usuario (PASAJERO, CONDUCTOR, ADMIN, OPERADOR)
|
||||
*/
|
||||
public async updateUserRole(userId: string, newRole: UserRole): Promise<IUser | null> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) throw new Error('Usuario no encontrado');
|
||||
|
||||
if (isCarlosSuperAdmin(user.telefono) || isCarlosSuperAdmin(user.email)) {
|
||||
user.rol = 'ADMIN';
|
||||
await user.save();
|
||||
return user;
|
||||
}
|
||||
|
||||
user.rol = newRole;
|
||||
await user.save();
|
||||
|
||||
// Si se promovió a CONDUCTOR y no tiene DriverProfile, crearlo
|
||||
if (newRole === 'CONDUCTOR') {
|
||||
const exists = await DriverProfile.findOne({ userId: user._id });
|
||||
if (!exists) {
|
||||
await DriverProfile.create({
|
||||
userId: user._id,
|
||||
placaVehiculo: 'PENDIENTE',
|
||||
modeloVehiculo: 'Por definir',
|
||||
capacidadMax: 4,
|
||||
estaActivo: false,
|
||||
coordenadasActuales: user.coordenadasDomicilio,
|
||||
ciudadBase: 'CALI',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configura métodos de doble factor (2FA) y biometría para seguridad
|
||||
*/
|
||||
public async updateSecuritySettings(
|
||||
userId: string,
|
||||
settings: {
|
||||
twoFactorEnabled?: boolean;
|
||||
twoFactorMethod?: 'whatsapp_otp' | 'biometric' | 'both';
|
||||
biometricEnabled?: boolean;
|
||||
}
|
||||
): Promise<IUser | null> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user) throw new Error('Usuario no encontrado');
|
||||
|
||||
if (settings.twoFactorEnabled !== undefined) user.twoFactorEnabled = settings.twoFactorEnabled;
|
||||
if (settings.twoFactorMethod !== undefined) user.twoFactorMethod = settings.twoFactorMethod;
|
||||
if (settings.biometricEnabled !== undefined) user.biometricEnabled = settings.biometricEnabled;
|
||||
|
||||
await user.save();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export const userAdminService = UserAdminService.getInstance();
|
||||
@ -0,0 +1,246 @@
|
||||
export interface WhatsAppConfig {
|
||||
evolutionApiUrl: string;
|
||||
apiKey: string;
|
||||
instanceName: string;
|
||||
adminPhone: string;
|
||||
}
|
||||
|
||||
export class WhatsAppNotificationService {
|
||||
private static instance: WhatsAppNotificationService;
|
||||
|
||||
private config: WhatsAppConfig = {
|
||||
evolutionApiUrl: process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co',
|
||||
apiKey: process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943',
|
||||
instanceName: process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira',
|
||||
adminPhone: process.env.ADMIN_WHATSAPP_PHONE || '573167455877',
|
||||
};
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): WhatsAppNotificationService {
|
||||
if (!WhatsAppNotificationService.instance) {
|
||||
WhatsAppNotificationService.instance = new WhatsAppNotificationService();
|
||||
}
|
||||
return WhatsAppNotificationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un mensaje de texto con formato WhatsApp a través de Evolution API
|
||||
*/
|
||||
public async sendTextMessage(phone: string, text: string): Promise<boolean> {
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
const target = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||
|
||||
const url = `${this.config.evolutionApiUrl}/message/sendText/${this.config.instanceName}`;
|
||||
const payload = {
|
||||
number: target,
|
||||
text,
|
||||
delay: 300,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: this.config.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error(`❌ [WhatsApp API Error] (${target}):`, errorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`✅ [WhatsApp Enviado Exitosamente a +${target}]`);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.error(`❌ [WhatsApp Exception] (${target}):`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. NOTIFICACIÓN AL PASAJERO (Cuando el viaje es aprobado y asignado a un conductor)
|
||||
*/
|
||||
public async notificarPasajeroViajeAprobado(params: {
|
||||
telefono: string;
|
||||
nombrePasajero: string;
|
||||
nombreConductor: string;
|
||||
telefonoConductor: string;
|
||||
placaVehiculo: string;
|
||||
modeloVehiculo: string;
|
||||
colorVehiculo?: string;
|
||||
horaEstimada: string;
|
||||
direccionRecogida: string;
|
||||
valor: number;
|
||||
estadoPago: string;
|
||||
linkTracking?: string;
|
||||
}): Promise<boolean> {
|
||||
const msg =
|
||||
`🚗 *PACIFICTRANS GO: ¡TU VIAJE CALI - BUENAVENTURA HA SIDO CONFIRMADO!*\n\n` +
|
||||
`Hola *${params.nombrePasajero}*, tu servicio ha sido aprobado por la central de despacho de Pacifictrans Go.\n\n` +
|
||||
`📋 *Detalles del Conductor y Vehículo:*\n` +
|
||||
`• *Conductor:* ${params.nombreConductor}\n` +
|
||||
`• *Vehículo:* ${params.modeloVehiculo} (${params.colorVehiculo || 'Blanco'})\n` +
|
||||
`• *Placa:* ${params.placaVehiculo}\n` +
|
||||
`• *Teléfono Conductor:* +${params.telefonoConductor.replace(/\D/g, '')}\n` +
|
||||
`• *Hora Aprox. Recogida:* ${params.horaEstimada}\n` +
|
||||
`• *Dirección:* ${params.direccionRecogida}\n\n` +
|
||||
`💳 *Estado de Pago:* ${params.estadoPago} ($${params.valor.toLocaleString('es-CO')} COP)\n\n` +
|
||||
(params.linkTracking ? `📍 *Seguimiento en Vivo:* ${params.linkTracking}\n\n` : '') +
|
||||
`_Por favor permanece atento en tu punto de recogida. ¡Buen viaje con Pacifictrans Go!_`;
|
||||
|
||||
return this.sendTextMessage(params.telefono, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. NOTIFICACIÓN AL CONDUCTOR (Ruta asignada con secuencia Waze y pasajeros)
|
||||
*/
|
||||
public async notificarConductorViajeAsignado(params: {
|
||||
telefonoConductor: string;
|
||||
nombreConductor: string;
|
||||
tripId: string;
|
||||
origen: string;
|
||||
destino: string;
|
||||
pasajeros: Array<{
|
||||
orden: number;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
direccionRecogida: string;
|
||||
estadoPago: string;
|
||||
valor: number;
|
||||
}>;
|
||||
primeraParadaWaze: string;
|
||||
pagoConductorTotal: number;
|
||||
}): Promise<boolean> {
|
||||
let listaPasajeros = '';
|
||||
params.pasajeros.forEach((p) => {
|
||||
listaPasajeros +=
|
||||
`\n📍 *Parada ${p.orden}:* ${p.nombre}\n` +
|
||||
`• Tel: +${p.telefono.replace(/\D/g, '')}\n` +
|
||||
`• Dirección: ${p.direccionRecogida}\n` +
|
||||
`• Pago: ${p.estadoPago} ($${p.valor.toLocaleString('es-CO')} COP)\n`;
|
||||
});
|
||||
|
||||
const msg =
|
||||
`🚐 *PACIFICTRANS GO: NUEVO VIAJE ASIGNADO (CALI ➔ BUENAVENTURA)*\n\n` +
|
||||
`¡Hola *${params.nombreConductor}*! Tienes un nuevo viaje asignado en Pacifictrans Go (#${params.tripId.slice(-6)}).\n\n` +
|
||||
`👥 *Pasajeros Agrupados (${params.pasajeros.length}/4 puestos):*` +
|
||||
listaPasajeros +
|
||||
`\n💰 *Tu Pago Neto (Descontada comisión):* $${params.pagoConductorTotal.toLocaleString('es-CO')} COP\n\n` +
|
||||
`🗺️ *Iniciar Navegación Waze (1ª Parada):*\n` +
|
||||
`👉 ${params.primeraParadaWaze}\n\n` +
|
||||
`_Recuerda confirmar cada recogida en la aplicación móvil Pacifictrans Go._`;
|
||||
|
||||
return this.sendTextMessage(params.telefonoConductor, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. NOTIFICACIÓN AL CONDUCTOR (Encomienda de mensajería asignada)
|
||||
*/
|
||||
public async notificarConductorPaqueteAsignado(params: {
|
||||
telefonoConductor: string;
|
||||
nombreConductor: string;
|
||||
tripId: string;
|
||||
remitenteNombre: string;
|
||||
remitenteTelefono: string;
|
||||
direccionRecogida: string;
|
||||
destinatarioNombre: string;
|
||||
destinatarioTelefono: string;
|
||||
direccionEntrega: string;
|
||||
tipoBulto: string;
|
||||
descripcion: string;
|
||||
pagoConductor: number;
|
||||
wazeRecogidaUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const msg =
|
||||
`📦 *PACIFICTRANS GO: NUEVA ENCOMIENDA ASIGNADA (100% PREPAGADA)*\n\n` +
|
||||
`Hola *${params.nombreConductor}*, tienes una entrega de mensajería programada (#${params.tripId.slice(-6)}).\n\n` +
|
||||
`📤 *Punto de Recogida:*\n` +
|
||||
`• Remitente: ${params.remitenteNombre} (+${params.remitenteTelefono.replace(/\D/g, '')})\n` +
|
||||
`• Dirección: ${params.direccionRecogida}\n\n` +
|
||||
`📥 *Punto de Entrega:*\n` +
|
||||
`• Destinatario: ${params.destinatarioNombre} (+${params.destinatarioTelefono.replace(/\D/g, '')})\n` +
|
||||
`• Dirección: ${params.direccionEntrega}\n\n` +
|
||||
`📦 *Paquete:* ${params.tipoBulto} - ${params.descripcion}\n` +
|
||||
`💰 *Pago por entrega:* $${params.pagoConductor.toLocaleString('es-CO')} COP\n\n` +
|
||||
`🗺️ *Navegar a Recogida en Waze:*\n👉 ${params.wazeRecogidaUrl}`;
|
||||
|
||||
return this.sendTextMessage(params.telefonoConductor, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. NOTIFICACIÓN AL ADMINISTRADOR (Carlos) - Nuevo servicio pendiente de revisión/aprobación
|
||||
*/
|
||||
public async notificarAdminNuevoServicio(params: {
|
||||
tipo: 'TRANSPORTE' | 'MENSAJERIA';
|
||||
tripId: string;
|
||||
solicitante: string;
|
||||
telefono: string;
|
||||
origen: string;
|
||||
destino: string;
|
||||
valorTotal: number;
|
||||
pasajerosCount: number;
|
||||
}): Promise<boolean> {
|
||||
const msg =
|
||||
`🔔 *PACIFICTRANS GO: NUEVA SOLICITUD EN ESPERA DE APROBACIÓN*\n\n` +
|
||||
`Central de Despacho Pacifictrans Go (Cali - Buenaventura):\n` +
|
||||
`• *Tipo:* ${params.tipo}\n` +
|
||||
`• *Ref:* #${params.tripId.slice(-6)}\n` +
|
||||
`• *Cliente:* ${params.solicitante} (+${params.telefono.replace(/\D/g, '')})\n` +
|
||||
`• *Ruta:* ${params.origen} ➔ ${params.destino}\n` +
|
||||
`• *Ocupación:* ${params.pasajerosCount}/4 pasajeros\n` +
|
||||
`• *Valor Total:* $${params.valorTotal.toLocaleString('es-CO')} COP\n\n` +
|
||||
`📲 *Panel de Control:* Entra para asignar conductor y aprobar el despacho.`;
|
||||
|
||||
return this.sendTextMessage(this.config.adminPhone, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. NOTIFICACIÓN AL ADMINISTRADOR (Carlos) - Pool lleno (4 de 4 cupos)
|
||||
*/
|
||||
public async notificarAdminPoolLleno(params: {
|
||||
tripId: string;
|
||||
ruta: string;
|
||||
totalRecaudo: number;
|
||||
conductorSugerido?: string;
|
||||
}): Promise<boolean> {
|
||||
const msg =
|
||||
`🔥 *PACIFICTRANS GO: ¡POOL DE 4 PASAJEROS COMPLETO Y LISTO PARA DESPACHO!*\n\n` +
|
||||
`El viaje #${params.tripId.slice(-6)} (${params.ruta}) ha alcanzado su capacidad máxima (4/4 cupos).\n` +
|
||||
`• *Recaudo viaje:* $${params.totalRecaudo.toLocaleString('es-CO')} COP\n` +
|
||||
(params.conductorSugerido ? `${params.conductorSugerido}\n` : '') +
|
||||
`\n⚡ *Acción Requerida:* Confirma la asignación del conductor y define la hora de salida en el panel. Una vez asignado, se notificará a los 4 pasajeros con sus datos y horarios.`;
|
||||
|
||||
return this.sendTextMessage(this.config.adminPhone, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. NOTIFICACIÓN AL USUARIO POR PENALIDAD DE CANCELACIÓN (Con enlace Bold)
|
||||
*/
|
||||
public async notificarUsuarioPenalidad(params: {
|
||||
telefono: string;
|
||||
nombreUsuario: string;
|
||||
tripId: string;
|
||||
montoPenalidad: number;
|
||||
linkBoldLiquidacion: string;
|
||||
}): Promise<boolean> {
|
||||
const msg =
|
||||
`⚠️ *PACIFICTRANS GO: AVISO DE PENALIDAD POR CANCELACIÓN TARDÍA*\n\n` +
|
||||
`Hola *${params.nombreUsuario}*, tu reserva #${params.tripId.slice(-6)} ya había sido confirmada y un conductor estaba en camino.\n\n` +
|
||||
`De acuerdo a las políticas de servicio de Pacifictrans Go, se ha generado un cargo de penalidad de *$${params.montoPenalidad.toLocaleString(
|
||||
'es-CO'
|
||||
)} COP*.\n\n` +
|
||||
`Tu cuenta se encuentra inhabilitada temporalmente para nuevas reservas hasta liquidar este saldo.\n\n` +
|
||||
`💳 *Paga aquí en línea con Bold (PSE, Nequi, Bre-B o Tarjetas) para reactivar tu cuenta inmediatamente:*\n` +
|
||||
`👉 ${params.linkBoldLiquidacion}\n\n` +
|
||||
`_Una vez realizado el pago, tu cuenta quedará desbloqueada automáticamente._`;
|
||||
|
||||
return this.sendTextMessage(params.telefono, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export const whatsAppNotificationService = WhatsAppNotificationService.getInstance();
|
||||
14
transporte-cali-buenaventura/backend/tsconfig.json
Normal file
14
transporte-cali-buenaventura/backend/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
version: '3.8'
|
||||
|
||||
# ==============================================================================
|
||||
# CONFIGURACIÓN OFICIAL DE MONGODB GEOESPACIAL PARA COOLIFY
|
||||
# Proyecto: Sistema de Transporte Especial y Mensajería Cali - Buenaventura
|
||||
# Motor: MongoDB 7.0 Community con soporte de WiredTiger e Índices 2dsphere
|
||||
# ==============================================================================
|
||||
|
||||
services:
|
||||
mongodb-transporte:
|
||||
image: mongo:7.0-jammy
|
||||
container_name: mongodb-cali-buenaventura
|
||||
restart: always
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-admin_transporte}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-InnovaTransporte2026!*}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-transporte_cali_buenaventura}
|
||||
command: >
|
||||
mongod
|
||||
--bind_ip_all
|
||||
--wiredTigerCacheSizeGB 1.5
|
||||
--nounixsocket
|
||||
--oplogSize 1024
|
||||
volumes:
|
||||
- mongodb_cali_data:/data/db
|
||||
- mongodb_cali_config:/data/configdb
|
||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/init-mongo.js:ro
|
||||
ports:
|
||||
# Exponer únicamente si se requiere acceso externo desde Compass;
|
||||
# si el backend corre en la misma red de Coolify, comunicarse por el nombre del contenedor
|
||||
- "27017:27017"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand(\"ping\").ok' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
mongodb_cali_data:
|
||||
name: coolify_mongodb_cali_data
|
||||
driver: local
|
||||
mongodb_cali_config:
|
||||
name: coolify_mongodb_cali_config
|
||||
driver: local
|
||||
119
transporte-cali-buenaventura/docs/COOLIFY_MONGODB_DEPLOY.md
Normal file
119
transporte-cali-buenaventura/docs/COOLIFY_MONGODB_DEPLOY.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Despliegue de MongoDB Propio (Self-Hosted) en Coolify
|
||||
|
||||
Este documento describe el procedimiento exacto para desplegar el servidor propio de MongoDB en **Coolify** para la plataforma de transporte y mensajería **Cali - Buenaventura**, asegurando soporte completo para **índices geoespaciales 2dsphere**, persistencia de datos y compatibilidad con Traefik.
|
||||
|
||||
---
|
||||
|
||||
## 1. Arquitectura de Despliegue en Coolify
|
||||
|
||||
- **Motor:** MongoDB Community 7.0 (WiredTiger Engine).
|
||||
- **Límites de Recursos:** Asignación fija de caché (`--wiredTigerCacheSizeGB 1.5`) y memoria máxima (2GB) para prevenir saturación de RAM en el VPS.
|
||||
- **Seguridad:** Autenticación obligatoria con usuario root y base de datos `transporte_cali_buenaventura`.
|
||||
- **Persistencia:** Volúmenes Docker gestionados por Coolify (`coolify_mongodb_cali_data`).
|
||||
- **Enrutamiento:** Red interna de Docker. El backend de Node.js se conecta internamente a través del nombre de servicio `mongodb-cali-buenaventura:27017` sin exponer puertos innecesarios a Internet, o con puerto `27017` si necesitas conectarte vía MongoDB Compass.
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuración en Coolify (Paso a Paso)
|
||||
|
||||
1. **Ingresar a tu panel de Coolify.**
|
||||
2. Ir a tu **Proyecto** y seleccionar el **Ambiente (Environment)** de producción.
|
||||
3. Hacer clic en **+ New Resource** ➔ **Docker Compose**.
|
||||
4. Asignar el nombre: `mongodb-transporte-cali`.
|
||||
5. En el campo de configuración Compose, pegar el contenido del archivo [`coolify-mongodb.docker-compose.yml`](file:///c:/Users/Carlos/dev/almeira-web/transporte-cali-buenaventura/coolify-mongodb.docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mongodb-transporte:
|
||||
image: mongo:7.0-jammy
|
||||
container_name: mongodb-cali-buenaventura
|
||||
restart: always
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-admin_transporte}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-InnovaTransporte2026!*}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-transporte_cali_buenaventura}
|
||||
command: >
|
||||
mongod
|
||||
--bind_ip_all
|
||||
--wiredTigerCacheSizeGB 1.5
|
||||
--nounixsocket
|
||||
--oplogSize 1024
|
||||
volumes:
|
||||
- mongodb_cali_data:/data/db
|
||||
- mongodb_cali_config:/data/configdb
|
||||
ports:
|
||||
- "27017:27017"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand(\"ping\").ok' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2048M
|
||||
|
||||
volumes:
|
||||
mongodb_cali_data:
|
||||
name: coolify_mongodb_cali_data
|
||||
driver: local
|
||||
mongodb_cali_config:
|
||||
name: coolify_mongodb_cali_config
|
||||
driver: local
|
||||
```
|
||||
|
||||
6. **Variables de Entorno en Coolify:**
|
||||
Configurar en la pestaña *Environment Variables*:
|
||||
```env
|
||||
MONGO_ROOT_USER=admin_transporte
|
||||
MONGO_ROOT_PASSWORD=TuPasswordSuperSeguro2026!*
|
||||
MONGO_DATABASE=transporte_cali_buenaventura
|
||||
```
|
||||
|
||||
7. **Desplegar:**
|
||||
Hacer clic en **Deploy**. Coolify levantará el contenedor de manera aislada y segura.
|
||||
|
||||
---
|
||||
|
||||
## 3. String de Conexión para el Backend
|
||||
|
||||
Una vez desplegado en Coolify, la variable `MONGODB_URI` en tu backend de Node.js debe configurarse como:
|
||||
|
||||
### Conexión Interna (si el backend corre en Coolify):
|
||||
```env
|
||||
MONGODB_URI=mongodb://admin_transporte:TuPasswordSuperSeguro2026!*@mongodb-cali-buenaventura:27017/transporte_cali_buenaventura?authSource=admin
|
||||
```
|
||||
|
||||
### Conexión Externa (VPS IP):
|
||||
```env
|
||||
MONGODB_URI=mongodb://admin_transporte:TuPasswordSuperSeguro2026!*@161.97.93.156:27017/transporte_cali_buenaventura?authSource=admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Verificación de Índices Geoespaciales 2dsphere
|
||||
|
||||
El backend ejecuta `autoIndex: true` de forma predeterminada. Puedes comprobar que los índices estén activos conectándote a `mongosh`:
|
||||
|
||||
```bash
|
||||
docker exec -it mongodb-cali-buenaventura mongosh -u admin_transporte -p TuPasswordSuperSeguro2026!* --authenticationDatabase admin
|
||||
```
|
||||
|
||||
Ejecutar dentro de la base de datos:
|
||||
```javascript
|
||||
use transporte_cali_buenaventura;
|
||||
db.users.getIndexes();
|
||||
db.driverprofiles.getIndexes();
|
||||
db.trips.getIndexes();
|
||||
```
|
||||
|
||||
Debes observar el índice:
|
||||
```json
|
||||
{ "coordenadasDomicilio": "2dsphere" }
|
||||
{ "coordenadasActuales": "2dsphere" }
|
||||
{ "origen.ubicacion": "2dsphere" }
|
||||
```
|
||||
Con esto, las consultas `$near`, `$geoWithin` y `$nearSphere` operarán con máxima velocidad milimétrica.
|
||||
@ -0,0 +1,666 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonGrid,
|
||||
IonRow,
|
||||
IonCol,
|
||||
IonCard,
|
||||
IonCardHeader,
|
||||
IonCardTitle,
|
||||
IonCardSubtitle,
|
||||
IonCardContent,
|
||||
IonBadge,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonProgressBar,
|
||||
IonSpinner,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
} from '@ionic/angular/standalone';
|
||||
|
||||
export interface DriverLiveDTO {
|
||||
driverId: string;
|
||||
userId: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
placaVehiculo: string;
|
||||
modeloVehiculo: string;
|
||||
colorVehiculo?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
ciudadBase: string;
|
||||
estadoOperativo: 'DISPONIBLE' | 'EN_VIAJE' | 'INACTIVO';
|
||||
tripIdActual?: string;
|
||||
pasajerosAbordo?: number;
|
||||
ultimaActualizacion: string;
|
||||
}
|
||||
|
||||
export interface ZoneDemandStat {
|
||||
zonaNombre: string;
|
||||
ciudad: 'CALI' | 'BUENAVENTURA';
|
||||
pasajerosEsperando: number;
|
||||
viajesPendientes: number;
|
||||
conductoresCercanos: number;
|
||||
deficitVehiculos: number;
|
||||
nivelUrgencia: 'NORMAL' | 'MODERADO' | 'CRITICO';
|
||||
coordenadasCentro: [number, number];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-live-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonGrid,
|
||||
IonRow,
|
||||
IonCol,
|
||||
IonCard,
|
||||
IonCardHeader,
|
||||
IonCardTitle,
|
||||
IonCardSubtitle,
|
||||
IonCardContent,
|
||||
IonBadge,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonProgressBar,
|
||||
IonSpinner,
|
||||
IonList,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonRefresher,
|
||||
IonRefresherContent,
|
||||
],
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar color="dark">
|
||||
<ion-title>Pacifictrans Go: Torre de Control & Telemetría GPS</ion-title>
|
||||
<div slot="end" class="live-status-indicator">
|
||||
<span class="pulsing-dot"></span>
|
||||
<span class="status-label">EN VIVO (Refresco: 10s)</span>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="dashboard-bg">
|
||||
<ion-refresher slot="fixed" (ionRefresh)="handleRefresh($event)">
|
||||
<ion-refresherContent></ion-refresherContent>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-grid class="dashboard-grid">
|
||||
<!-- 1. TARJETAS DE KPIS PRINCIPALES -->
|
||||
<ion-row>
|
||||
<ion-col size="12" size-md="3">
|
||||
<div class="kpi-card glow-blue">
|
||||
<div class="kpi-header">
|
||||
<span class="kpi-title">Conductores Activos</span>
|
||||
<span class="kpi-icon">🚗</span>
|
||||
</div>
|
||||
<div class="kpi-value">{{ kpis.conductoresActivosEnRuta }}</div>
|
||||
<div class="kpi-sub">
|
||||
<span class="highlight">{{ kpis.conductoresDisponibles }}</span> Disponibles para despacho
|
||||
</div>
|
||||
</div>
|
||||
</ion-col>
|
||||
|
||||
<ion-col size="12" size-md="3">
|
||||
<div class="kpi-card glow-amber">
|
||||
<div class="kpi-header">
|
||||
<span class="kpi-title">Pasajeros en Espera</span>
|
||||
<span class="kpi-icon">👥</span>
|
||||
</div>
|
||||
<div class="kpi-value">{{ demandaResumen.totalPasajerosEsperando }}</div>
|
||||
<div class="kpi-sub">
|
||||
Necesidad: <span class="highlight">{{ demandaResumen.vehiculosNecesariosParaEvacuar }} autos</span> (Pool 4)
|
||||
</div>
|
||||
</div>
|
||||
</ion-col>
|
||||
|
||||
<ion-col size="12" size-md="3">
|
||||
<div class="kpi-card" [ngClass]="demandaResumen.deficitGlobal > 0 ? 'glow-red' : 'glow-green'">
|
||||
<div class="kpi-header">
|
||||
<span class="kpi-title">Déficit de Vehículos</span>
|
||||
<span class="kpi-icon">⚠️</span>
|
||||
</div>
|
||||
<div class="kpi-value">{{ demandaResumen.deficitGlobal }}</div>
|
||||
<div class="kpi-sub">
|
||||
{{ demandaResumen.deficitGlobal > 0 ? 'Faltan vehículos para cubrir demanda' : 'Oferta y demanda equilibradas' }}
|
||||
</div>
|
||||
</div>
|
||||
</ion-col>
|
||||
|
||||
<ion-col size="12" size-md="3">
|
||||
<div class="kpi-card glow-purple">
|
||||
<div class="kpi-header">
|
||||
<span class="kpi-title">Comisión Admin Hoy</span>
|
||||
<span class="kpi-icon">💰</span>
|
||||
</div>
|
||||
<div class="kpi-value">\${{ (kpis.comisionesAdminHoy || 0) | number }} COP</div>
|
||||
<div class="kpi-sub">
|
||||
Ocupación promedio: <span class="highlight">{{ kpis.tasaOcupacionPromedio }}/4 puestos</span>
|
||||
</div>
|
||||
</div>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<!-- 2. SECCIÓN PRINCIPAL: RADAR GPS Y ALERTAS DE DEMANDA -->
|
||||
<ion-row>
|
||||
<!-- RADAR DE TELEMETRÍA GPS DE CONDUCTORES -->
|
||||
<ion-col size="12" size-lg="7">
|
||||
<ion-card class="radar-card">
|
||||
<ion-card-header>
|
||||
<div class="card-header-flex">
|
||||
<div>
|
||||
<ion-card-title>Radar GPS de Conductores en Tiempo Real</ion-card-title>
|
||||
<ion-card-subtitle>Monitoreo satelital y ocupación vehicular</ion-card-subtitle>
|
||||
</div>
|
||||
<ion-badge color="primary">{{ drivers.length }} Rastreados</ion-badge>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content>
|
||||
<!-- Radar visual con animación y coordenadas -->
|
||||
<div class="radar-canvas-container">
|
||||
<div class="radar-sweep"></div>
|
||||
<div class="map-overlay-info">
|
||||
<span>Corredor Troncal Cali ➔ Buenaventura</span>
|
||||
</div>
|
||||
|
||||
<!-- Marcadores dinámicos de conductores -->
|
||||
<div
|
||||
*ngFor="let d of drivers"
|
||||
class="driver-marker"
|
||||
[ngClass]="d.estadoOperativo === 'DISPONIBLE' ? 'marker-available' : 'marker-busy'"
|
||||
[style.top.%]="getMarkerTopPercent(d.lat)"
|
||||
[style.left.%]="getMarkerLeftPercent(d.lng)"
|
||||
(click)="selectDriver(d)"
|
||||
>
|
||||
<span class="driver-ping"></span>
|
||||
<span class="driver-pin-label">{{ d.placaVehiculo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalle del Conductor Seleccionado -->
|
||||
<div *ngIf="selectedDriver" class="selected-driver-box">
|
||||
<div class="driver-box-grid">
|
||||
<div>
|
||||
<h4 class="m-0">{{ selectedDriver.nombre }}</h4>
|
||||
<p class="m-0 text-muted">
|
||||
{{ selectedDriver.placaVehiculo }} • {{ selectedDriver.modeloVehiculo }}
|
||||
({{ selectedDriver.colorVehiculo || 'Blanco' }})
|
||||
</p>
|
||||
<p class="m-0 text-coords">
|
||||
GPS: Lat {{ selectedDriver.lat.toFixed(4) }}, Lng {{ selectedDriver.lng.toFixed(4) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="driver-box-actions">
|
||||
<ion-badge [color]="selectedDriver.estadoOperativo === 'DISPONIBLE' ? 'success' : 'warning'">
|
||||
{{ selectedDriver.estadoOperativo }}
|
||||
</ion-badge>
|
||||
<ion-button
|
||||
size="small"
|
||||
color="success"
|
||||
(click)="contactViaWhatsApp(selectedDriver.telefono, selectedDriver.nombre)"
|
||||
>
|
||||
WhatsApp
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabla de Conductores -->
|
||||
<div class="driver-table-wrap">
|
||||
<table class="driver-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Conductor</th>
|
||||
<th>Placa</th>
|
||||
<th>Estado</th>
|
||||
<th>Ocupación</th>
|
||||
<th>Última Señal</th>
|
||||
<th>Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let d of drivers" (click)="selectDriver(d)">
|
||||
<td><strong>{{ d.nombre }}</strong></td>
|
||||
<td><span class="badge-plate">{{ d.placaVehiculo }}</span></td>
|
||||
<td>
|
||||
<span class="status-pill" [ngClass]="d.estadoOperativo === 'DISPONIBLE' ? 'pill-green' : 'pill-orange'">
|
||||
{{ d.estadoOperativo }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ d.pasajerosAbordo || 0 }}/4 puestos</td>
|
||||
<td>{{ d.ultimaActualizacion | date:'shortTime' }}</td>
|
||||
<td>
|
||||
<button class="btn-chat" (click)="contactViaWhatsApp(d.telefono, d.nombre); $event.stopPropagation()">
|
||||
Despachar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
|
||||
<!-- ANÁLISIS DE DEMANDA POR ZONAS Y DÉFICIT -->
|
||||
<ion-col size="12" size-lg="5">
|
||||
<ion-card class="demand-card">
|
||||
<ion-card-header>
|
||||
<ion-card-title>Zonas con Necesidad de Vehículos</ion-card-title>
|
||||
<ion-card-subtitle>Déficit predictivo para balancear oferta</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content>
|
||||
<div *ngFor="let z of demandZones" class="zone-demand-item">
|
||||
<div class="zone-header">
|
||||
<div>
|
||||
<span class="zone-name">{{ z.zonaNombre }}</span>
|
||||
<span class="zone-city">({{ z.ciudad }})</span>
|
||||
</div>
|
||||
<ion-badge [color]="getUrgencyColor(z.nivelUrgencia)">
|
||||
{{ z.nivelUrgencia }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
|
||||
<div class="zone-metrics-row">
|
||||
<div class="zone-metric">
|
||||
<span class="zm-label">Pasajeros:</span>
|
||||
<span class="zm-val">{{ z.pasajerosEsperando }}</span>
|
||||
</div>
|
||||
<div class="zone-metric">
|
||||
<span class="zm-label">Autos Cercanos:</span>
|
||||
<span class="zm-val">{{ z.conductoresCercanos }}</span>
|
||||
</div>
|
||||
<div class="zone-metric">
|
||||
<span class="zm-label">Déficit:</span>
|
||||
<span class="zm-val deficit-val" [class.red-text]="z.deficitVehiculos > 0">
|
||||
{{ z.deficitVehiculos }} autos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de nivel de saturación -->
|
||||
<div class="progress-wrap">
|
||||
<ion-progress-bar
|
||||
[value]="getSaturationPercent(z)"
|
||||
[color]="getUrgencyColor(z.nivelUrgencia)"
|
||||
></ion-progress-bar>
|
||||
</div>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dashboard-bg {
|
||||
--background: #0f172a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.dashboard-grid {
|
||||
padding: 16px;
|
||||
}
|
||||
.live-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
.pulsing-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #22c55e;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px #22c55e;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); opacity: 0.8; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(0.95); opacity: 0.8; }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.kpi-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.glow-blue { border-left: 4px solid #38bdf8; }
|
||||
.glow-amber { border-left: 4px solid #f59e0b; }
|
||||
.glow-red { border-left: 4px solid #ef4444; }
|
||||
.glow-green { border-left: 4px solid #10b981; }
|
||||
.glow-purple { border-left: 4px solid #a855f7; }
|
||||
.kpi-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.kpi-title {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.kpi-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
.kpi-sub {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.highlight {
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
.radar-card, .demand-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.card-header-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.radar-canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
background: radial-gradient(circle at center, #1e293b 0%, #090d16 100%);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.radar-sweep {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: conic-gradient(from 0deg at 50% 50%, rgba(56, 189, 248, 0.15) 0deg, transparent 60deg);
|
||||
animation: radarRotate 4s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes radarRotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.map-overlay-info {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.driver-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.driver-marker:hover {
|
||||
transform: translate(-50%, -50%) scale(1.4);
|
||||
}
|
||||
.marker-available {
|
||||
background-color: #22c55e;
|
||||
box-shadow: 0 0 10px #22c55e;
|
||||
}
|
||||
.marker-busy {
|
||||
background-color: #f59e0b;
|
||||
box-shadow: 0 0 10px #f59e0b;
|
||||
}
|
||||
.driver-pin-label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -18px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.selected-driver-box {
|
||||
background: #0f172a;
|
||||
border: 1px solid #38bdf8;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.driver-box-grid {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.text-muted { color: #94a3b8; font-size: 0.85rem; }
|
||||
.text-coords { color: #64748b; font-size: 0.75rem; font-family: monospace; }
|
||||
.m-0 { margin: 0; }
|
||||
.driver-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.driver-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.driver-table th {
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.driver-table td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
.badge-plate {
|
||||
background: #334155;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.status-pill {
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pill-green { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
|
||||
.pill-orange { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
|
||||
.btn-chat {
|
||||
background: #0284c7;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btn-chat:hover { background: #0369a1; }
|
||||
.zone-demand-item {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.zone-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.zone-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.zone-city { font-size: 0.75rem; color: #64748b; margin-left: 4px; }
|
||||
.zone-metrics-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.zm-val { font-weight: 700; color: #ffffff; }
|
||||
.deficit-val { color: #f59e0b; }
|
||||
.red-text { color: #ef4444; }
|
||||
.progress-wrap { margin-top: 4px; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AdminLiveDashboardComponent implements OnInit, OnDestroy {
|
||||
private refreshTimer: any;
|
||||
private apiUrl = 'https://api-transporte.almeira.com.co/api';
|
||||
|
||||
drivers: DriverLiveDTO[] = [];
|
||||
selectedDriver: DriverLiveDTO | null = null;
|
||||
|
||||
demandZones: ZoneDemandStat[] = [];
|
||||
demandaResumen = {
|
||||
totalPasajerosEsperando: 0,
|
||||
vehiculosNecesariosParaEvacuar: 0,
|
||||
conductoresDisponiblesGlobal: 0,
|
||||
deficitGlobal: 0,
|
||||
};
|
||||
|
||||
kpis = {
|
||||
totalConductoresRegistrados: 0,
|
||||
conductoresActivosEnRuta: 0,
|
||||
conductoresDisponibles: 0,
|
||||
solicitudesPendientesAprobacion: 0,
|
||||
viajesActivosEnProgreso: 0,
|
||||
viajesCompletadosHoy: 0,
|
||||
tasaOcupacionPromedio: 0,
|
||||
ingresosTotalesHoy: 0,
|
||||
comisionesAdminHoy: 0,
|
||||
penalidadesRecaudadasHoy: 0,
|
||||
penalidadesPendientesCobro: 0,
|
||||
};
|
||||
|
||||
async ngOnInit() {
|
||||
await this.fetchDashboardData();
|
||||
// Refresco automático de telemetría cada 10 segundos
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.fetchDashboardData();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRefresh(event: any) {
|
||||
await this.fetchDashboardData();
|
||||
event.target.complete();
|
||||
}
|
||||
|
||||
async fetchDashboardData() {
|
||||
try {
|
||||
const [driversRes, demandRes, kpisRes] = await Promise.all([
|
||||
fetch(`${this.apiUrl}/admin/drivers-realtime`).then((r) => r.json()),
|
||||
fetch(`${this.apiUrl}/admin/demand-analytics`).then((r) => r.json()),
|
||||
fetch(`${this.apiUrl}/admin/kpis`).then((r) => r.json()),
|
||||
]);
|
||||
|
||||
if (driversRes.success) {
|
||||
this.drivers = driversRes.drivers || [];
|
||||
if (!this.selectedDriver && this.drivers.length > 0) {
|
||||
this.selectedDriver = this.drivers[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (demandRes.success) {
|
||||
this.demandZones = demandRes.zonas || [];
|
||||
this.demandaResumen = demandRes.resumenDemanda || this.demandaResumen;
|
||||
}
|
||||
|
||||
if (kpisRes.success) {
|
||||
this.kpis = kpisRes.kpis || this.kpis;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error cargando telemetría del dashboard administrativo:', err);
|
||||
}
|
||||
}
|
||||
|
||||
selectDriver(driver: DriverLiveDTO) {
|
||||
this.selectedDriver = driver;
|
||||
}
|
||||
|
||||
contactViaWhatsApp(telefono: string, nombre: string) {
|
||||
const cleanPhone = telefono.replace(/\D/g, '');
|
||||
const msg = encodeURIComponent(
|
||||
`Hola ${nombre}, te contactamos desde la central de despacho Cali-Buenaventura. Te necesitamos en ruta por alta demanda.`
|
||||
);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${msg}`, '_system');
|
||||
}
|
||||
|
||||
// Mapeo proporcional de coordenadas del corredor Cali (lat 3.4) a Buenaventura (lat 3.88)
|
||||
getMarkerTopPercent(lat: number): number {
|
||||
const minLat = 3.35;
|
||||
const maxLat = 3.92;
|
||||
const norm = (lat - minLat) / (maxLat - minLat);
|
||||
return Math.max(10, Math.min(90, Math.round((1 - norm) * 100)));
|
||||
}
|
||||
|
||||
getMarkerLeftPercent(lng: number): number {
|
||||
const minLng = -77.08;
|
||||
const maxLng = -76.48;
|
||||
const norm = (lng - minLng) / (maxLng - minLng);
|
||||
return Math.max(10, Math.min(90, Math.round(norm * 100)));
|
||||
}
|
||||
|
||||
getUrgencyColor(urgencia: string): string {
|
||||
switch (urgencia) {
|
||||
case 'CRITICO':
|
||||
return 'danger';
|
||||
case 'MODERADO':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
getSaturationPercent(zone: ZoneDemandStat): number {
|
||||
const maxEsperado = 12;
|
||||
return Math.min(1.0, zone.pasajerosEsperando / maxEsperado);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,374 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonCard,
|
||||
IonCardHeader,
|
||||
IonCardSubtitle,
|
||||
IonCardTitle,
|
||||
IonCardContent,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonBadge,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonInput,
|
||||
IonSpinner,
|
||||
IonList,
|
||||
IonProgressBar,
|
||||
} from '@ionic/angular/standalone';
|
||||
import { WazeNavigationService, WazeStop } from '../../services/waze-navigation.service';
|
||||
import { TripService, BookingPayload } from '../../services/trip.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-trip-pooling',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonContent,
|
||||
IonCard,
|
||||
IonCardHeader,
|
||||
IonCardSubtitle,
|
||||
IonCardTitle,
|
||||
IonCardContent,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonBadge,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
IonInput,
|
||||
IonSpinner,
|
||||
IonList,
|
||||
IonProgressBar,
|
||||
],
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Pacifictrans Go</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<!-- ALERTA DE PENALIDAD ACTIVA / BLOQUEO -->
|
||||
<ion-card *ngIf="userPenaltyBalance > 0" color="danger">
|
||||
<ion-card-header>
|
||||
<ion-card-title>Cuenta Bloqueada por Penalidad</ion-card-title>
|
||||
<ion-card-subtitle>Sanción por cancelación tardía</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>
|
||||
Tienes un saldo pendiente de <strong>\${{ userPenaltyBalance | number }} COP</strong>.
|
||||
Debes saldar esta penalidad para poder solicitar nuevos viajes o servicios de mensajería.
|
||||
</p>
|
||||
<ion-button
|
||||
expand="block"
|
||||
color="light"
|
||||
class="ion-margin-top"
|
||||
*ngIf="boldPenaltyCheckoutUrl"
|
||||
(click)="openUrl(boldPenaltyCheckoutUrl)"
|
||||
>
|
||||
Pagar Penalidad con Bold (PSE / Nequi / QR Bre-B)
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- FORMULARIO DE RESERVA (Si no tiene penalidad) -->
|
||||
<ion-card *ngIf="userPenaltyBalance === 0 && !activeTrip">
|
||||
<ion-card-header>
|
||||
<ion-card-title>Solicitud de Puesto (Pool de 4)</ion-card-title>
|
||||
<ion-card-subtitle>Agrupación inteligente por cercanía</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-list>
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Ruta</ion-label>
|
||||
<ion-select [(ngModel)]="booking.sentido">
|
||||
<ion-select-option value="CALI_A_BUENAVENTURA">Cali ➔ Buenaventura</ion-select-option>
|
||||
<ion-select-option value="BUENAVENTURA_A_CALI">Buenaventura ➔ Cali</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Dirección de recogida en Cali</ion-label>
|
||||
<ion-input
|
||||
placeholder="Ej. Calle 5 # 38-25, San Fernando"
|
||||
[(ngModel)]="booking.direccionTexto"
|
||||
></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Parada fija en Buenaventura</ion-label>
|
||||
<ion-select [(ngModel)]="selectedBuenaventuraStop">
|
||||
<ion-select-option *ngFor="let stop of bvStops" [value]="stop">
|
||||
{{ stop.nombre }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="stacked">Método de Pago Preferido</ion-label>
|
||||
<ion-select [(ngModel)]="booking.metodoPago">
|
||||
<ion-select-option value="BOLD">Bold (PSE, Tarjetas, Bre-B)</ion-select-option>
|
||||
<ion-select-option value="NEQUI">Nequi Digital</ion-select-option>
|
||||
<ion-select-option value="EFECTIVO">Efectivo al abordar</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-button
|
||||
expand="block"
|
||||
class="ion-margin-top"
|
||||
[disabled]="loading"
|
||||
(click)="submitBooking()"
|
||||
>
|
||||
<ion-spinner *ngIf="loading"></ion-spinner>
|
||||
<span *ngIf="!loading">Solicitar Puesto ($35.000 COP)</span>
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- DETALLE DEL VIAJE ACTIVO Y NAVEGACIÓN WAZE -->
|
||||
<ion-card *ngIf="activeTrip">
|
||||
<ion-card-header>
|
||||
<div class="flex-header">
|
||||
<ion-card-title>Viaje #{{ activeTrip._id?.slice(-6) }}</ion-card-title>
|
||||
<ion-badge [color]="getStatusColor(activeTrip.estado)">
|
||||
{{ activeTrip.estado }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
<ion-card-subtitle>
|
||||
{{ activeTrip.origen?.ciudad }} ➔ {{ activeTrip.destino?.ciudad }}
|
||||
</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content>
|
||||
<!-- Mensaje de estado PENDING_APPROVAL -->
|
||||
<div *ngIf="activeTrip.estado === 'PENDING_APPROVAL'" class="notice-box">
|
||||
<p>⏳ <strong>En revisión del Administrador</strong></p>
|
||||
<p>Tu solicitud ha sido agrupada en el pool. Recibirás una notificación push cuando sea aprobada.</p>
|
||||
</div>
|
||||
|
||||
<!-- Información del Conductor (Cuando pasa a ACTIVE) -->
|
||||
<div *ngIf="activeTrip.estado === 'ACTIVE'" class="driver-box">
|
||||
<h4>🚗 Conductor Asignado</h4>
|
||||
<p><strong>Nombre:</strong> {{ activeTrip.conductorId?.nombre || 'Confirmado' }}</p>
|
||||
<p><strong>Hora estimada de recogida:</strong> {{ activeTrip.horaEstimadaRecogida | date:'shortTime' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Secuencia de Navegación Waze para Conductores -->
|
||||
<div *ngIf="wazeStops.length > 0" class="waze-section ion-margin-top">
|
||||
<h4>🗺️ Ruta Optimizada Waze (Parada por Parada)</h4>
|
||||
<ion-progress-bar [value]="wazeProgress.porcentaje / 100"></ion-progress-bar>
|
||||
<p class="small-text">{{ wazeProgress.completadas }} de {{ wazeProgress.total }} paradas completadas</p>
|
||||
|
||||
<div class="current-stop-card" *ngIf="currentStop">
|
||||
<p><strong>Siguiente recogida:</strong> {{ currentStop.nombrePasajero }}</p>
|
||||
<p class="address-text">{{ currentStop.direccion }}</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
<ion-button color="success" (click)="openWazeForCurrentStop()">
|
||||
Abrir en Waze
|
||||
</ion-button>
|
||||
<ion-button fill="outline" (click)="completeStop()">
|
||||
Marcar Recogido
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón de Cancelación -->
|
||||
<ion-button
|
||||
expand="block"
|
||||
color="danger"
|
||||
fill="clear"
|
||||
class="ion-margin-top"
|
||||
(click)="cancelTrip()"
|
||||
>
|
||||
Cancelar Reserva
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-content>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.flex-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.notice-box {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.driver-box {
|
||||
background: #e8f4fd;
|
||||
color: #0b5394;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.current-stop-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.address-text {
|
||||
font-size: 0.9rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.small-text {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class TripPoolingComponent implements OnInit {
|
||||
private wazeService = inject(WazeNavigationService);
|
||||
private tripService = inject(TripService);
|
||||
|
||||
loading: boolean = false;
|
||||
userPenaltyBalance: number = 0;
|
||||
boldPenaltyCheckoutUrl?: string;
|
||||
|
||||
activeTrip: any = null;
|
||||
|
||||
bvStops = [
|
||||
{ nombre: 'Terminal de Transportes Buenaventura', coordenadas: [-77.018, 3.882] },
|
||||
{ nombre: 'Centro / Muelle Turístico Buenaventura', coordenadas: [-77.031, 3.889] },
|
||||
{ nombre: 'Sector El Piñal Buenaventura', coordenadas: [-77.025, 3.879] },
|
||||
];
|
||||
|
||||
selectedBuenaventuraStop = this.bvStops[0];
|
||||
|
||||
booking: BookingPayload = {
|
||||
usuarioId: 'usr-demo-123',
|
||||
direccionTexto: '',
|
||||
coordenadas: [-76.532, 3.4516],
|
||||
sentido: 'CALI_A_BUENAVENTURA',
|
||||
puntoFijoBuenaventura: this.selectedBuenaventuraStop,
|
||||
metodoPago: 'BOLD',
|
||||
};
|
||||
|
||||
wazeStops: WazeStop[] = [];
|
||||
currentStop: WazeStop | null = null;
|
||||
wazeProgress = { completadas: 0, total: 0, porcentaje: 0 };
|
||||
|
||||
async ngOnInit() {
|
||||
await this.checkUserStatus();
|
||||
}
|
||||
|
||||
async checkUserStatus() {
|
||||
try {
|
||||
const res = await this.tripService.getUserStatus(this.booking.usuarioId);
|
||||
if (res.success) {
|
||||
this.userPenaltyBalance = res.montoPenalidad || 0;
|
||||
this.boldPenaltyCheckoutUrl = res.linkLiquidacionBold;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error verificando penalidades de usuario:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async submitBooking() {
|
||||
this.loading = true;
|
||||
this.booking.puntoFijoBuenaventura = this.selectedBuenaventuraStop;
|
||||
|
||||
try {
|
||||
const res = await this.tripService.requestPoolTrip(this.booking);
|
||||
if (res.success) {
|
||||
this.activeTrip = res.trip;
|
||||
if (res.secuenciaParadas) {
|
||||
this.wazeStops = res.secuenciaParadas.map((p: any) => ({
|
||||
orden: p.orden,
|
||||
nombrePasajero: `Parada ${p.orden}`,
|
||||
direccion: p.direccion,
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
completada: p.completada,
|
||||
}));
|
||||
this.wazeService.setStops(this.wazeStops);
|
||||
this.updateWazeState();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`Error al solicitar viaje: ${err.message}`);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
openWazeForCurrentStop() {
|
||||
this.wazeService.navigateToCurrentStop();
|
||||
}
|
||||
|
||||
completeStop() {
|
||||
this.wazeService.completeCurrentStopAndAdvance();
|
||||
this.updateWazeState();
|
||||
}
|
||||
|
||||
updateWazeState() {
|
||||
this.currentStop = this.wazeService.getCurrentStop();
|
||||
this.wazeProgress = this.wazeService.getProgress();
|
||||
}
|
||||
|
||||
getStatusColor(estado: string): string {
|
||||
switch (estado) {
|
||||
case 'PENDING_APPROVAL':
|
||||
return 'warning';
|
||||
case 'ACTIVE':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'primary';
|
||||
case 'COMPLETED':
|
||||
return 'medium';
|
||||
case 'CANCELLED':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTrip() {
|
||||
if (!confirm('¿Seguro que deseas cancelar? Si el viaje ya está aprobado, incurrirás en una penalidad.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.tripService.cancelTrip(this.activeTrip._id, this.booking.usuarioId);
|
||||
alert(res.message);
|
||||
this.activeTrip = null;
|
||||
await this.checkUserStatus();
|
||||
} catch (err: any) {
|
||||
alert(`Error cancelando viaje: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
openUrl(url: string) {
|
||||
window.open(url, '_system');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface BookingPayload {
|
||||
usuarioId: string;
|
||||
direccionTexto: string;
|
||||
coordenadas: [number, number];
|
||||
sentido: 'CALI_A_BUENAVENTURA' | 'BUENAVENTURA_A_CALI';
|
||||
puntoFijoBuenaventura: {
|
||||
nombre: string;
|
||||
coordenadas: [number, number];
|
||||
};
|
||||
metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO';
|
||||
}
|
||||
|
||||
export interface PackagePayload {
|
||||
remitenteId: string;
|
||||
remitenteNombre: string;
|
||||
remitenteTelefono: string;
|
||||
remitenteEmail?: string;
|
||||
direccionRecogida: string;
|
||||
coordenadasRecogida: [number, number];
|
||||
ciudadOrigen: 'CALI' | 'BUENAVENTURA';
|
||||
|
||||
destinatarioNombre: string;
|
||||
destinatarioTelefono: string;
|
||||
direccionEntrega: string;
|
||||
coordenadasEntrega: [number, number];
|
||||
ciudadDestino: 'CALI' | 'BUENAVENTURA';
|
||||
|
||||
tipoBulto: 'SOBRE' | 'MEDIANO' | 'GRANDE';
|
||||
descripcion: string;
|
||||
pesoAproxKg?: number;
|
||||
incluirSeguro?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TripService {
|
||||
private apiUrl: string = 'https://api-transporte.almeira.com.co/api';
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Solicita un puesto en el pool de transporte Cali <-> Buenaventura
|
||||
*/
|
||||
public async requestPoolTrip(payload: BookingPayload): Promise<any> {
|
||||
const res = await fetch(`${this.apiUrl}/trips/pool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cotiza y registra el despacho de una encomienda 100% prepagada con Bold
|
||||
*/
|
||||
public async requestPackageDelivery(payload: PackagePayload): Promise<any> {
|
||||
const res = await fetch(`${this.apiUrl}/trips/package`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela un viaje evaluando penalidades
|
||||
*/
|
||||
public async cancelTrip(tripId: string, usuarioId: string, motivo?: string): Promise<any> {
|
||||
const res = await fetch(`${this.apiUrl}/trips/${tripId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ usuarioId, motivo }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta el estado de un usuario (verificando si tiene saldo de penalidad y link de Bold)
|
||||
*/
|
||||
public async getUserStatus(phoneOrId: string): Promise<any> {
|
||||
const res = await fetch(`${this.apiUrl}/users/status?query=${encodeURIComponent(phoneOrId)}`);
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AppLauncher } from '@capacitor/app-launcher';
|
||||
|
||||
export interface WazeStop {
|
||||
orden: number;
|
||||
nombrePasajero: string;
|
||||
telefonoPasajero?: string;
|
||||
direccion: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
completada: boolean;
|
||||
estadoPago?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WazeNavigationService {
|
||||
private currentStopIndex: number = 0;
|
||||
private stops: WazeStop[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Construye el enlace universal (deep link) oficial de Waze con auto-navegación
|
||||
* Formato: https://waze.com/ul?ll=lat,lng&navigate=yes
|
||||
*/
|
||||
public buildWazeDeepLink(lat: number, lng: number): string {
|
||||
return `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el esquema nativo de URL para la aplicación Waze en Android e iOS
|
||||
* Formato: waze://?ll=lat,lng&navigate=yes
|
||||
*/
|
||||
public buildWazeNativeScheme(lat: number, lng: number): string {
|
||||
return `waze://?ll=${lat},${lng}&navigate=yes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga una lista ordenada de paradas optimizadas para el conductor
|
||||
*/
|
||||
public setStops(stops: WazeStop[]): void {
|
||||
this.stops = stops.map((s, index) => ({
|
||||
...s,
|
||||
orden: index + 1,
|
||||
completada: s.completada || false,
|
||||
}));
|
||||
this.currentStopIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la parada actual activa en la secuencia de recogida
|
||||
*/
|
||||
public getCurrentStop(): WazeStop | null {
|
||||
if (this.currentStopIndex < this.stops.length) {
|
||||
return this.stops[this.currentStopIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre Waze apuntando a las coordenadas especificadas.
|
||||
* Utiliza Capacitor AppLauncher si está disponible en dispositivo nativo,
|
||||
* o realiza fallback transparente al navegador web.
|
||||
*/
|
||||
public async launchWaze(lat: number, lng: number): Promise<boolean> {
|
||||
const nativeUrl = this.buildWazeNativeScheme(lat, lng);
|
||||
const webUrl = this.buildWazeDeepLink(lat, lng);
|
||||
|
||||
try {
|
||||
// 1. Verificar si la app nativa de Waze está instalada en el dispositivo
|
||||
const canOpen = await AppLauncher.canOpenUrl({ url: nativeUrl });
|
||||
|
||||
if (canOpen.value) {
|
||||
await AppLauncher.openUrl({ url: nativeUrl });
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('⚠️ [Waze Service] Error al intentar abrir app nativa con AppLauncher, usando fallback web:', err);
|
||||
}
|
||||
|
||||
// 2. Fallback: Abrir deep link universal en navegador o intent del sistema
|
||||
try {
|
||||
window.open(webUrl, '_system');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('❌ [Waze Service] Fallo total al abrir enlace de navegación:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navega inmediatamente a la parada actual en Waze
|
||||
*/
|
||||
public async navigateToCurrentStop(): Promise<boolean> {
|
||||
const current = this.getCurrentStop();
|
||||
if (!current) {
|
||||
console.warn('⚠️ [Waze Service] No hay paradas pendientes por visitar.');
|
||||
return false;
|
||||
}
|
||||
return this.launchWaze(current.lat, current.lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca la parada actual como completada (pasajero recogido) y avanza a la siguiente
|
||||
*/
|
||||
public completeCurrentStopAndAdvance(): WazeStop | null {
|
||||
if (this.currentStopIndex < this.stops.length) {
|
||||
this.stops[this.currentStopIndex].completada = true;
|
||||
this.currentStopIndex++;
|
||||
}
|
||||
return this.getCurrentStop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve el progreso total de la ruta (ej. 2 de 4 paradas)
|
||||
*/
|
||||
public getProgress(): { completadas: number; total: number; porcentaje: number } {
|
||||
const total = this.stops.length;
|
||||
const completadas = this.stops.filter((s) => s.completada).length;
|
||||
const porcentaje = total > 0 ? Math.round((completadas / total) * 100) : 0;
|
||||
return { completadas, total, porcentaje };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user