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
|
# production
|
||||||
/build
|
/build
|
||||||
|
/dist
|
||||||
|
**/dist/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.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