feat: tarifas base configurables, sistema de cupones/bonos de descuento y reserva multi-cupos con datos por pasajero
This commit is contained in:
parent
e80309adad
commit
4df6698114
@ -71,17 +71,16 @@ def main():
|
||||
print("[4/4] Enviando notificación de Pacifictrans Go a WhatsApp (+57 316 745 5877)...", flush=True)
|
||||
|
||||
text_msg = (
|
||||
"🚗 *Pacifictrans Go: Seguridad de Rutas, Dashboard Admin Exclusivo & Grupos de WhatsApp Separados*\n\n"
|
||||
"¡Hola Carlos! Se ha desplegado una nueva versión con la reestructuración completa de seguridad y canales de WhatsApp:\n\n"
|
||||
"🚗 *Pacifictrans Go: Tarifas Base Configurables, Sistema de Bonos de Descuento & Reserva Multi-Cupos*\n\n"
|
||||
"¡Hola Carlos! Se ha actualizado la plataforma con las nuevas capacidades operativas y comerciales:\n\n"
|
||||
"📱 *App Oficial:* `Pacifictrans Go`\n"
|
||||
"📦 *Archivo APK:* `PacifictransGo.apk` (37.07 MB)\n"
|
||||
"🆔 *Package ID:* `co.pacifictrans.go`\n\n"
|
||||
"⚡ *Novedades y Mejoras Implementadas:*\n"
|
||||
"• 🛡️ *Seguridad de Rutas por Permisos de Rol:* Protección de vistas y endpoints API (`ADMIN`, `CONDUCTOR`, `PASAJERO`). Si un usuario no autorizado o visitante intenta ingresar a la consola de administración (`/#admin`) o conductor (`/#conductor`), el sistema activa un Escudo de Seguridad interactivo con acceso restringido.\n"
|
||||
"• 🖥️ *Dashboard Exclusivo para Administradores:* La página principal quedó 100% limpia para clientes (sólo reserva de cupo, calculadora de fletes, muro de reseñas y descarga de app). La administración ahora opera en su propia Central de Mando Ejecutiva con Radar GPS en vivo, Telemetría, Control Bre-B y Tarifas.\n"
|
||||
"• 👥 *Separación de Grupos de WhatsApp por App:* Las notificaciones operativas de Pacific Trans GO se envían automáticamente al grupo *'Pacific Trans Notificaciones'* y las de Dripzone al grupo *'Dripzone Notificaciones'*. En caso de que no exista el grupo o el bot no esté agregado, hace fallback inmediato a tu número personal (+57 316 745 5877).\n"
|
||||
"• 🔍 *Auto-Detección de Grupos Evolution:* Botón en el panel administrativo que consulta Evolution API en vivo (`/api/admin/whatsapp/groups`) y resuelve los JIDs oficiales `@g.us`.\n"
|
||||
"• 💰 *Bandeja de Transferencias Bre-B:* Control de solicitudes de pago con ciclo de compensación inmediata 24/7 y generación de recibos digitales.\n\n"
|
||||
"⚡ *Novedades Implementadas:*\n"
|
||||
"• ⚙️ *Definición de Tarifas Base en Panel Admin:* Control en vivo persistido en MongoDB para la tarifa fija de pasajeros ($35.000 COP), radio de pooling, penalidad por cancelación, tarifa base de encomiendas ($25.000 COP), costo por km ($1.200 COP/km), factores de peso/volumen (sobre, mediano, grande), seguro y comisión de plataforma (15%).\n"
|
||||
"• 🎟️ *Sistema de Bonos & Cupones de Descuento:* Creación y administración centralizada de promociones en el Administrador. Permite descuentos en porcentaje (%) o valor fijo en pesos ($ COP), configurando si aplican para VIAJES, ENCOMIENDAS o AMBOS, subtotal mínimo y límite de canjes. Incluye cupones semilla predefinidos (ej. PACIFICO10, BONOVIAJE5K, ENVIOYA, PROMO20).\n"
|
||||
"• 💳 *Validación y Descuento Reactivo en Pasarela:* Los clientes pueden ingresar su cupón tanto en la reserva de viajes como en la cotización de fletes. El sistema valida el monto mínimo y calcula el descuento en tiempo real antes de generar la orden en Bold Smart Checkout.\n"
|
||||
"• 👥 *Reserva Multi-Cupo (1 a 4 Asientos):* Posibilidad de reservar varios cupos a la vez ingresando los datos de cada pasajero (Nombre completo, Documento C.C., Teléfono/WhatsApp, dirección de recogida domiciliaria independiente y notas de equipaje).\n\n"
|
||||
f"🔗 *Enlace de Descarga Directa:*\n👉 {APK_URL}"
|
||||
)
|
||||
|
||||
|
||||
@ -2009,7 +2009,423 @@ h1, h2, h3, h4, .brand-title {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
MULTI-SEATS & DYNAMIC PASSENGER FORM STYLES
|
||||
========================================================================== */
|
||||
.badge-seats-counter {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.seat-qty-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.seat-qty-selector {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-qty-seat {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: var(--text-main);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-qty-seat:hover {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-qty-seat.active {
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.25) 0%, rgba(14, 165, 233, 0.15) 100%);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 15px rgba(6, 182, 212, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.seat-qty-number {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-qty-seat.active .seat-qty-number {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.seat-qty-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-qty-seat.active .seat-qty-desc {
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tarjetas individuales de Pasajeros */
|
||||
.passenger-card {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.15rem;
|
||||
position: relative;
|
||||
transition: all 0.25s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.passenger-card:hover {
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
box-shadow: 0 6px 20px -5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.passenger-card.titular {
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.passenger-card.companion {
|
||||
border-left: 3px solid #a855f7;
|
||||
}
|
||||
|
||||
.passenger-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.passenger-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.passenger-card-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.passenger-card-badge.titular {
|
||||
background: rgba(6, 182, 212, 0.18);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.passenger-card-badge.companion {
|
||||
background: rgba(168, 85, 247, 0.18);
|
||||
color: #c084fc;
|
||||
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
.passenger-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 580px) {
|
||||
.passenger-grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.same-address-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: #cbd5e1;
|
||||
background: rgba(2, 6, 23, 0.4);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.same-address-toggle input[type="checkbox"] {
|
||||
accent-color: var(--accent-cyan);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Selector de Métodos de Pago */
|
||||
.payment-method-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.payment-method-selector {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-method-card {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.payment-method-card:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.payment-method-card.active {
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 12px rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
|
||||
.payment-method-card input[type="radio"] {
|
||||
accent-color: var(--accent-cyan);
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.payment-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.payment-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.payment-sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}/* ==========================================================================
|
||||
SISTEMA DE BONOS Y CUPONES DE DESCUENTO (VIAJES Y ENCOMIENDAS)
|
||||
========================================================================== */
|
||||
.coupon-input-group {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.coupon-input-group:focus-within {
|
||||
border-color: rgba(6, 182, 212, 0.5);
|
||||
box-shadow: 0 0 14px rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.coupon-feedback-pill {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.coupon-feedback-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
.coupon-feedback-error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.summary-discount-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: #34d399;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px dashed rgba(52, 211, 153, 0.25);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.coupon-code-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: rgba(14, 165, 233, 0.18);
|
||||
color: #38bdf8;
|
||||
border: 1px solid rgba(14, 165, 233, 0.4);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.coupon-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.coupon-type-porcentaje {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #c084fc;
|
||||
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
.coupon-type-fijo {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.coupon-service-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.coupon-service-todos {
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.coupon-service-viajes {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.coupon-service-encomiendas {
|
||||
background: rgba(251, 146, 60, 0.15);
|
||||
color: #fdba74;
|
||||
}
|
||||
|
||||
.coupons-table-responsive {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.coupons-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.coupons-table th {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
color: #94a3b8;
|
||||
padding: 0.85rem 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.coupons-table td {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #cbd5e1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.coupons-table tr:hover {
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
|
||||
.btn-coupon-toggle {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-coupon-toggle.active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.btn-coupon-toggle.inactive {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.btn-coupon-delete {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-coupon-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@ -221,7 +221,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="cali-address-group">
|
||||
<label class="form-label" for="cali-address">Dirección de Recogida en Cali (Domicilio)</label>
|
||||
<label class="form-label" for="cali-address">Dirección Principal de Recogida en Cali (Domicilio)</label>
|
||||
<input type="text" class="form-input" id="cali-address" placeholder="Ej: Calle 5 #66-20, Barrio Limonar, Cali" value="">
|
||||
</div>
|
||||
|
||||
@ -235,20 +235,86 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1rem; margin-bottom: 0;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="user-name">Nombre Completo</label>
|
||||
<input type="text" class="form-input" id="user-name" placeholder="Ej: María Fernanda Gómez" value="">
|
||||
<!-- SELECTOR DE CANTIDAD DE CUPOS (1 a 4 Pasajeros) -->
|
||||
<div class="form-group" style="margin-top: 1.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||
<label class="form-label" style="margin-bottom: 0;">👥 ¿Cuántos cupos deseas reservar?</label>
|
||||
<span id="badge-cupos-selected" class="badge-seats-counter">1 Cupo Seleccionado</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="user-phone">WhatsApp (+57)</label>
|
||||
<input type="tel" class="form-input" id="user-phone" placeholder="Ej: 300 123 4567" value="">
|
||||
<div class="seat-qty-selector" id="seat-qty-selector">
|
||||
<button type="button" class="btn-qty-seat active" data-seats="1">
|
||||
<span class="seat-qty-number">1</span>
|
||||
<span class="seat-qty-desc">Solo Yo</span>
|
||||
</button>
|
||||
<button type="button" class="btn-qty-seat" data-seats="2">
|
||||
<span class="seat-qty-number">2</span>
|
||||
<span class="seat-qty-desc">2 Cupos</span>
|
||||
</button>
|
||||
<button type="button" class="btn-qty-seat" data-seats="3">
|
||||
<span class="seat-qty-number">3</span>
|
||||
<span class="seat-qty-desc">3 Cupos</span>
|
||||
</button>
|
||||
<button type="button" class="btn-qty-seat" data-seats="4">
|
||||
<span class="seat-qty-number">4</span>
|
||||
<span class="seat-qty-desc">Vehículo 4/4</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="user-notes">Notas / Equipaje</label>
|
||||
<input type="text" class="form-input" id="user-notes" placeholder="Maletín mediano de 10kg">
|
||||
<!-- CONTENEDOR DINÁMICO DE DATOS DE CADA PASAJERO -->
|
||||
<div style="margin-top: 1.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
||||
<label class="form-label" style="margin-bottom: 0;">📋 Información de los Pasajeros</label>
|
||||
<span style="font-size: 0.75rem; color: #94a3b8;">Requerido para Seguro SOAT y Planilla</span>
|
||||
</div>
|
||||
<div id="passengers-cards-container" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<!-- Se genera dinámicamente con JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SELECTOR DE MÉTODO DE PAGO -->
|
||||
<div class="form-group" style="margin-top: 1.5rem;">
|
||||
<label class="form-label">💳 Método de Pago</label>
|
||||
<div class="payment-method-selector" id="payment-method-options">
|
||||
<label class="payment-method-card active" data-method="BOLD">
|
||||
<input type="radio" name="pool-payment-radio" value="BOLD" checked>
|
||||
<div class="payment-card-body">
|
||||
<span class="payment-title">Bold Smart Checkout</span>
|
||||
<span class="payment-sub">QR Bre-B, Nequi, PSE, Tarjeta</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="payment-method-card" data-method="EFECTIVO">
|
||||
<input type="radio" name="pool-payment-radio" value="EFECTIVO">
|
||||
<div class="payment-card-body">
|
||||
<span class="payment-title">Efectivo al Conductor</span>
|
||||
<span class="payment-sub">Pagas directamente al abordar</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="payment-method-card" data-method="NEQUI">
|
||||
<input type="radio" name="pool-payment-radio" value="NEQUI">
|
||||
<div class="payment-card-body">
|
||||
<span class="payment-title">Nequi Directo</span>
|
||||
<span class="payment-sub">Transferencia móvil inmediata</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BONO O CUPÓN DE DESCUENTO (PASAJEROS) -->
|
||||
<div class="coupon-input-group" style="margin-top: 1.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
|
||||
<label class="form-label" for="pool-coupon-code" style="margin-bottom: 0; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem;">
|
||||
<span>🎟️</span> ¿Tienes un Bono o Cupón de Descuento?
|
||||
</label>
|
||||
<span style="font-size: 0.72rem; color: #38bdf8; font-weight: 600;">(Ej: PACIFICO10, BONOVIAJE5K)</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" class="form-input" id="pool-coupon-code" placeholder="Código (ej: PACIFICO10)" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0;">
|
||||
<button type="button" class="btn-toggle-payment" id="btn-apply-pool-coupon" style="white-space: nowrap; padding: 0.5rem 1.2rem; font-weight: 700;">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
<div id="pool-coupon-feedback" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -263,53 +329,69 @@
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span style="color: var(--text-muted);">Capacidad del Vehículo:</span>
|
||||
<span style="color: #fff; font-weight: 600;">Máximo 4 Pasajeros (1 cupo solicitado)</span>
|
||||
<span style="color: #fff; font-weight: 600;" id="summary-capacity-text">Máximo 4 Pasajeros (1 cupo solicitado)</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span style="color: var(--text-muted);">Cupos a Reservar:</span>
|
||||
<span style="color: var(--accent-cyan); font-weight: 700;" id="summary-seats-pill">1 Pasajero (1 cupo)</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span style="color: var(--text-muted);">Recogida Puerta a Puerta:</span>
|
||||
<span style="color: var(--accent-cyan); font-weight: 600;">Incluida en Cali</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span style="color: var(--text-muted);">Navegación Conductor:</span>
|
||||
<span style="color: #fff; font-weight: 600;">Waze Deep Link con TSP</span>
|
||||
<span style="color: var(--text-muted);">Seguro Contractual:</span>
|
||||
<span style="color: var(--accent-emerald); font-weight: 600;">Póliza y Planilla SOAT</span>
|
||||
</div>
|
||||
|
||||
<div class="bold-gateway-badge">
|
||||
<div class="summary-item" id="summary-subtotal-row" style="display: none; border-top: 1px dashed var(--border-glass); padding-top: 0.5rem; margin-top: 0.5rem;">
|
||||
<span style="color: var(--text-muted);">Subtotal:</span>
|
||||
<span style="color: #fff; font-weight: 600;" id="summary-subtotal-price">$35.000 COP</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-discount-row" id="summary-discount-row" style="display: none;">
|
||||
<span>🎟️ Descuento Cupón:</span>
|
||||
<span id="summary-discount-amount">- $0 COP</span>
|
||||
</div>
|
||||
|
||||
<div class="bold-gateway-badge" id="summary-gateway-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"></rect>
|
||||
<line x1="2" y1="10" x2="22" y2="10"></line>
|
||||
</svg>
|
||||
<span>Pago 100% Prepagado con <strong>Bold Smart Checkout</strong> (QR Bre-B, Nequi, PSE)</span>
|
||||
<span>Pago seguro con <strong>Bold Smart Checkout</strong> (QR Bre-B, Nequi, PSE)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="summary-total">
|
||||
<span style="font-weight: 600; color: #fff;">Tarifa por Pasajero:</span>
|
||||
<span class="total-price">$35.000 COP</span>
|
||||
<div class="summary-total" style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span style="font-weight: 600; color: #fff;" id="summary-price-label">Total a Pagar (1 Cupo):</span>
|
||||
<span class="total-price" id="summary-total-price">$35.000 COP</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="btn-book-pool" style="margin-top: 1rem;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||
</svg>
|
||||
Confirmar y Pagar Cupo ($35.000 COP)
|
||||
<span id="btn-book-text">Confirmar y Pagar 1 Cupo ($35.000 COP)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerta tras completar cupo -->
|
||||
<div id="pool-dispatch-alert" style="display: none; margin-top: 2rem; background: rgba(245, 158, 11, 0.12); border: 1px solid rgba(245, 158, 11, 0.4); border-radius: 14px; padding: 1.5rem;">
|
||||
<!-- Alerta tras completar cupo o confirmación -->
|
||||
<div id="pool-dispatch-alert" style="display: none; margin-top: 2rem; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.4); border-radius: 14px; padding: 1.5rem;">
|
||||
<div style="display: flex; gap: 1rem; align-items: flex-start;">
|
||||
<span style="font-size: 2rem;">🚗💨</span>
|
||||
<div>
|
||||
<h4 style="color: #fbbf24; font-size: 1.15rem; margin-bottom: 0.4rem;">¡Vehículo con 4/4 Cupos Completados!</h4>
|
||||
<p style="color: #cbd5e1; font-size: 0.92rem; margin-bottom: 0.75rem;">
|
||||
El viaje ha cambiado su estado a <code>POOL_COMPLETED</code>. Se ha enviado una alerta inmediata al Administrador para seleccionar el conductor de acuerdo a la cercanía de su residencia en Cali respecto a la primera recogida.
|
||||
<span style="font-size: 2.2rem;" id="alert-icon">🚗💨</span>
|
||||
<div style="flex: 1;">
|
||||
<h4 style="color: #38bdf8; font-size: 1.15rem; margin-bottom: 0.4rem;" id="alert-title">¡Reserva de Cupos Confirmada!</h4>
|
||||
<p style="color: #cbd5e1; font-size: 0.92rem; margin-bottom: 0.75rem;" id="alert-message">
|
||||
Tu reserva ha sido procesada exitosamente en el sistema de pooling inteligente de Pacifictrans Go.
|
||||
</p>
|
||||
<div style="font-size: 0.85rem; color: #94a3b8;">
|
||||
📲 Una vez asignado, se disparará automáticamente el mensaje de WhatsApp a los 4 pasajeros con los datos del conductor y horario exacto de recogida.
|
||||
<div id="alert-passengers-breakdown" style="margin-bottom: 0.75rem; font-size: 0.88rem; color: #e2e8f0;"></div>
|
||||
<div id="alert-bold-payment-area" style="display: none; margin-top: 1rem;"></div>
|
||||
<div style="font-size: 0.85rem; color: #94a3b8;" id="alert-footer-note">
|
||||
📲 Notificaciones de confirmación e itinerario enviadas por WhatsApp oficial (+57 316 745 5877).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -358,6 +440,23 @@
|
||||
<label class="form-label" for="package-desc">Descripción del Contenido</label>
|
||||
<input type="text" class="form-input" id="package-desc" placeholder="Repuestos automotrices / Ropa / Medicamentos">
|
||||
</div>
|
||||
|
||||
<!-- BONO O CUPÓN DE DESCUENTO (ENCOMIENDAS) -->
|
||||
<div class="coupon-input-group" style="margin-top: 1.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;">
|
||||
<label class="form-label" for="freight-coupon-code" style="margin-bottom: 0; font-size: 0.85rem; display: flex; align-items: center; gap: 0.35rem;">
|
||||
<span>🎟️</span> ¿Tienes un Bono o Cupón para tu Envío?
|
||||
</label>
|
||||
<span style="font-size: 0.72rem; color: #fbbf24; font-weight: 600;">(Ej: ENVIOYA, PACIFICO10)</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<input type="text" class="form-input" id="freight-coupon-code" placeholder="Código de cupón (ej: ENVIOYA)" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0;">
|
||||
<button type="button" class="btn-toggle-payment" id="btn-apply-freight-coupon" style="white-space: nowrap; padding: 0.5rem 1.2rem; font-weight: 700;">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
<div id="freight-coupon-feedback" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-box">
|
||||
@ -380,6 +479,16 @@
|
||||
<span style="color: var(--accent-emerald); font-weight: 600;" id="freight-insurance-cost">$1.500 COP</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-item" id="freight-subtotal-row" style="display: none; border-top: 1px dashed var(--border-glass); padding-top: 0.5rem; margin-top: 0.5rem;">
|
||||
<span style="color: var(--text-muted);">Subtotal Flete:</span>
|
||||
<span style="color: #fff; font-weight: 600;" id="freight-subtotal-price">$197.100 COP</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-discount-row" id="freight-discount-row" style="display: none;">
|
||||
<span>🎟️ Descuento Cupón:</span>
|
||||
<span id="freight-discount-amount">- $0 COP</span>
|
||||
</div>
|
||||
|
||||
<div class="bold-gateway-badge">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@ -395,7 +504,7 @@
|
||||
<span class="total-price" id="freight-total-price">$197.100 COP</span>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" style="margin-top: 1rem;">
|
||||
<button class="btn-primary" id="btn-book-freight" style="margin-top: 1rem;">
|
||||
Generar Guía y Enlace de Pago Bold
|
||||
</button>
|
||||
</div>
|
||||
@ -600,7 +709,10 @@
|
||||
💰 2. Control de Pagos, Comisiones & Bre-B <span id="admin-payouts-badge" style="display:none; background:#ec4899; color:#fff; font-size:0.72rem; padding:1px 6px; border-radius:10px; margin-left:4px; font-weight:700;">0</span>
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-tarifas" class="admin-pill-btn">
|
||||
⚙️ 3. Tarifas & Canales de WhatsApp
|
||||
⚙️ 3. Tarifas Base & Canales
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-cupones" class="admin-pill-btn">
|
||||
🎟️ 4. Bonos & Cupones de Descuento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -617,11 +729,14 @@
|
||||
📡 1. Radar Satelital & Despacho
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-tarifas" class="admin-pill-btn">
|
||||
⚙️ 2. Tarifas & Canales de Atención
|
||||
⚙️ 2. Tarifas Base & Canales
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-liquidaciones" class="admin-pill-btn">
|
||||
💰 3. Control de Pagos, Comisiones & Bre-B <span id="admin-payouts-badge" style="display:none; background:#ec4899; color:#fff; font-size:0.72rem; padding:1px 6px; border-radius:10px; margin-left:4px; font-weight:700;">0</span>
|
||||
</button>
|
||||
<button type="button" id="btn-admin-subtab-cupones" class="admin-pill-btn">
|
||||
🎟️ 4. Bonos & Cupones de Descuento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -907,6 +1022,163 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUB-PANEL 4: SISTEMA DE BONOS Y CUPONES DE DESCUENTO -->
|
||||
<div id="admin-view-cupones" style="display: none;">
|
||||
<!-- BANNER INFORMATIVO CUPONES -->
|
||||
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(14, 165, 233, 0.2)); border: 1px solid rgba(14, 165, 233, 0.35); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.75rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.35rem;">
|
||||
<span style="font-size: 1.4rem;">🎟️</span>
|
||||
<strong style="color: #fff; font-size: 1.15rem;">Sistema Centralizado de Bonos & Cupones de Descuento</strong>
|
||||
</div>
|
||||
<p style="color: #cbd5e1; font-size: 0.88rem; margin: 0; max-width: 680px;">
|
||||
Crea y administra promociones para el corredor Cali - Buenaventura. Define descuentos en <strong>Porcentaje (%)</strong> o <strong>Monto Fijo ($ COP)</strong> aplicables a viajes de pasajeros, encomiendas o ambos.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" id="btn-refresh-coupons" class="btn-toggle-payment" style="padding: 0.6rem 1.25rem;">
|
||||
🔄 Actualizar Cupones
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIS DE CUPONES -->
|
||||
<div class="financial-summary-grid" style="margin-bottom: 1.75rem;">
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #38bdf8;">
|
||||
<div class="fin-metric-label">Total Cupones Creados</div>
|
||||
<div class="fin-metric-val" id="kpi-total-coupons" style="color: #38bdf8;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">En base de datos MongoDB</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #34d399;">
|
||||
<div class="fin-metric-label">Cupones Activos</div>
|
||||
<div class="fin-metric-val" id="kpi-active-coupons" style="color: #34d399;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">Listos para canje inmediato</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #f59e0b;">
|
||||
<div class="fin-metric-label">Total de Canjes</div>
|
||||
<div class="fin-metric-val" id="kpi-redeemed-coupons" style="color: #fbbf24;">0</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">Usados en reservas o envíos</span>
|
||||
</div>
|
||||
<div class="fin-metric-card" style="border-top: 3px solid #c084fc;">
|
||||
<div class="fin-metric-label">Modalidades de Bono</div>
|
||||
<div class="fin-metric-val" style="color: #c084fc; font-size: 1.25rem; font-weight: 700; margin-top: 0.35rem;">% y $ COP</div>
|
||||
<span style="font-size: 0.72rem; color: #94a3b8;">Porcentaje y Monto Fijo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1.5rem; align-items: flex-start; margin-bottom: 2rem;">
|
||||
<!-- FORMULARIO DE CREACIÓN DE CUPÓN -->
|
||||
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.5rem;">
|
||||
<h3 style="color: #38bdf8; font-size: 1.15rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>➕</span> Crear Nuevo Bono / Cupón de Descuento
|
||||
</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1.25rem;">
|
||||
Completa los datos para habilitar una nueva promoción en la pasarela y en las reservas directas.
|
||||
</p>
|
||||
|
||||
<form id="form-create-coupon">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-code-input">Código Promocional (Mayúsculas) *</label>
|
||||
<input type="text" class="form-input" id="coupon-code-input" placeholder="Ej: CALIBUENA15" style="text-transform: uppercase; font-weight: 700; letter-spacing: 0.05em;" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-desc-input">Descripción / Motivo de la Campaña</label>
|
||||
<input type="text" class="form-input" id="coupon-desc-input" placeholder="Ej: 15% de descuento especial por lanzamiento">
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-type-select">Tipo de Descuento *</label>
|
||||
<select class="form-select" id="coupon-type-select">
|
||||
<option value="PORCENTAJE">Porcentaje (% de Descuento)</option>
|
||||
<option value="MONTO_FIJO">Monto Fijo ($ COP de Descuento)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-value-input" id="label-coupon-value">Valor (% Descuento) *</label>
|
||||
<input type="number" class="form-input" id="coupon-value-input" placeholder="Ej: 15" min="1" step="1" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-service-select">Aplica Para *</label>
|
||||
<select class="form-select" id="coupon-service-select">
|
||||
<option value="TODOS">🌟 Todos (Viajes y Encomiendas)</option>
|
||||
<option value="VIAJES">🚗 Exclusivo Viajes de Pasajeros</option>
|
||||
<option value="ENCOMIENDAS">📦 Exclusivo Fletes de Encomiendas</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-min-subtotal">Subtotal Mínimo ($ COP)</label>
|
||||
<input type="number" class="form-input" id="coupon-min-subtotal" placeholder="0 (Sin mínimo)" min="0" step="1000" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col" style="gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-usage-limit">Límite Máximo de Canjes</label>
|
||||
<input type="number" class="form-input" id="coupon-usage-limit" placeholder="Dejar vacío para ilimitado" min="1" step="1">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coupon-exp-date">Fecha de Expiración</label>
|
||||
<input type="date" class="form-input" id="coupon-exp-date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="same-address-toggle" for="coupon-active-check" style="margin-bottom: 0;">
|
||||
<input type="checkbox" id="coupon-active-check" checked>
|
||||
<span>🟢 Activar cupón inmediatamente para su uso público</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="coupon-form-feedback" style="display: none; margin-bottom: 1rem;"></div>
|
||||
|
||||
<button type="submit" class="btn-primary" id="btn-submit-coupon" style="margin-top: 0.5rem;">
|
||||
➕ Crear y Publicar Cupón de Descuento
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- LISTADO DE CUPONES VIGENTES -->
|
||||
<div style="background: rgba(15, 23, 42, 0.7); border: 1px solid var(--border-glass); border-radius: 12px; padding: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h3 style="color: #fff; font-size: 1.15rem; margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span>🎟️</span> Catálogo de Cupones en MongoDB
|
||||
</h3>
|
||||
<span style="font-size: 0.8rem; color: #94a3b8;" id="coupons-counter-label">Cargando...</span>
|
||||
</div>
|
||||
|
||||
<div class="coupons-table-responsive">
|
||||
<table class="coupons-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Descuento</th>
|
||||
<th>Aplica Para</th>
|
||||
<th>Canjes / Límite</th>
|
||||
<th>Estado</th>
|
||||
<th>Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="coupons-table-body">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #94a3b8;">
|
||||
Cargando catálogo de cupones...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ import { authService } from './services/auth.service';
|
||||
import { statsReviewsService } from './services/stats-reviews.service';
|
||||
import { settlementService } from './services/settlement.service';
|
||||
import { whatsAppNotificationService } from './services/whatsapp-notification.service';
|
||||
import { couponService } from './services/coupon.service';
|
||||
import { Trip } from './models/Trip';
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
@ -316,6 +317,48 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendJson(200, { success: true, message: 'Tarifas actualizadas correctamente en MongoDB', config: updated });
|
||||
}
|
||||
|
||||
// 13.2.1. Gestión de Bonos y Cupones de Descuento (Viajes y Encomiendas)
|
||||
if (pathname === '/api/coupons' && req.method === 'GET') {
|
||||
const coupons = await couponService.getAllCoupons();
|
||||
return sendJson(200, { success: true, count: coupons.length, coupons });
|
||||
}
|
||||
|
||||
if (pathname === '/api/coupons' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
try {
|
||||
const coupon = await couponService.createCoupon(body);
|
||||
return sendJson(201, { success: true, coupon });
|
||||
} catch (err: any) {
|
||||
return sendJson(400, { success: false, error: err.message || 'Error al crear cupón' });
|
||||
}
|
||||
}
|
||||
|
||||
if ((pathname === '/api/coupons/toggle' || (pathname.startsWith('/api/coupons/') && pathname.endsWith('/toggle'))) && (req.method === 'POST' || req.method === 'PATCH')) {
|
||||
const body = await getJsonBody();
|
||||
const id = body.id || body.couponId || pathname.split('/')[3];
|
||||
if (!id) return sendJson(400, { success: false, error: 'ID de cupón requerido' });
|
||||
const coupon = await couponService.toggleCouponStatus(id, body.estaActivo);
|
||||
return sendJson(200, { success: true, coupon });
|
||||
}
|
||||
|
||||
if ((pathname === '/api/coupons/delete' && req.method === 'POST') || (pathname.startsWith('/api/coupons/') && req.method === 'DELETE')) {
|
||||
const body = await getJsonBody();
|
||||
const id = pathname.split('/')[3] || body.id || body.couponId;
|
||||
if (!id) return sendJson(400, { success: false, error: 'ID de cupón requerido' });
|
||||
const success = await couponService.deleteCoupon(id);
|
||||
return sendJson(200, { success });
|
||||
}
|
||||
|
||||
if (pathname === '/api/coupons/validate' && req.method === 'POST') {
|
||||
const body = await getJsonBody();
|
||||
const validation = await couponService.validateCoupon(
|
||||
body.codigo || body.code,
|
||||
body.serviceType || body.tipoServicio || 'VIAJES',
|
||||
parseFloat(body.subtotal || 0)
|
||||
);
|
||||
return sendJson(200, validation);
|
||||
}
|
||||
|
||||
// 13.3. Estadísticas de Viajes y Sistema de Calificaciones/Comentarios
|
||||
if (pathname === '/api/stats-reviews' && req.method === 'GET') {
|
||||
const data = await statsReviewsService.getStatsAndReviews();
|
||||
@ -499,6 +542,7 @@ async function bootstrap() {
|
||||
try {
|
||||
await connectDatabase();
|
||||
console.log('✅ [MongoDB] Conectado exitosamente en segundo plano.');
|
||||
await couponService.ensureSeedCoupons();
|
||||
} catch (err: any) {
|
||||
console.warn(`⚠️ [MongoDB Warning] Conexión no disponible (${err.message || 'ENOTFOUND'}). Reintentando en 10s...`);
|
||||
setTimeout(tryConnect, 10000);
|
||||
|
||||
83
transporte-cali-buenaventura/backend/src/models/Coupon.ts
Normal file
83
transporte-cali-buenaventura/backend/src/models/Coupon.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
|
||||
export type CouponDiscountType = 'PORCENTAJE' | 'MONTO_FIJO';
|
||||
export type CouponApplicableService = 'TODOS' | 'VIAJES' | 'ENCOMIENDAS';
|
||||
|
||||
export interface ICoupon extends Document {
|
||||
codigo: string;
|
||||
descripcion: string;
|
||||
tipoDescuento: CouponDiscountType;
|
||||
valorDescuento: number;
|
||||
aplicaPara: CouponApplicableService;
|
||||
montoMinimoSubtotal: number;
|
||||
limiteUsosTotales?: number | null;
|
||||
canjesActuales: number;
|
||||
fechaExpiracion?: Date | null;
|
||||
estaActivo: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const CouponSchema = new Schema<ICoupon>(
|
||||
{
|
||||
codigo: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
uppercase: true,
|
||||
trim: true,
|
||||
index: true,
|
||||
},
|
||||
descripcion: {
|
||||
type: String,
|
||||
default: '',
|
||||
trim: true,
|
||||
},
|
||||
tipoDescuento: {
|
||||
type: String,
|
||||
enum: ['PORCENTAJE', 'MONTO_FIJO'],
|
||||
required: true,
|
||||
default: 'PORCENTAJE',
|
||||
},
|
||||
valorDescuento: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
},
|
||||
aplicaPara: {
|
||||
type: String,
|
||||
enum: ['TODOS', 'VIAJES', 'ENCOMIENDAS'],
|
||||
required: true,
|
||||
default: 'TODOS',
|
||||
},
|
||||
montoMinimoSubtotal: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
limiteUsosTotales: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
canjesActuales: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
},
|
||||
fechaExpiracion: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
estaActivo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
export const Coupon: Model<ICoupon> =
|
||||
mongoose.models.Coupon || mongoose.model<ICoupon>('Coupon', CouponSchema);
|
||||
@ -35,9 +35,10 @@ export interface ITripLiquidacion {
|
||||
}
|
||||
|
||||
export interface ITripPassenger {
|
||||
usuarioId: Types.ObjectId;
|
||||
usuarioId?: Types.ObjectId;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
documentoIdentidad?: string;
|
||||
direccionRecogida: string;
|
||||
coordenadas: IGeoPoint;
|
||||
estadoPago: PassengerPaymentStatus;
|
||||
@ -45,6 +46,8 @@ export interface ITripPassenger {
|
||||
secuenciaOrden?: number;
|
||||
horaEstimadaRecogida?: string;
|
||||
recogido?: boolean;
|
||||
notas?: string;
|
||||
esTitular?: boolean;
|
||||
}
|
||||
|
||||
export interface IPackageInfo {
|
||||
@ -128,6 +131,14 @@ export interface ITrip extends Document {
|
||||
// Control Financiero, Liquidación de Saldo y Despacho Bre-B
|
||||
liquidacion?: ITripLiquidacion;
|
||||
|
||||
// Descuento aplicado por cupón promocional
|
||||
descuentoCupon?: {
|
||||
codigo: string;
|
||||
tipo: 'PORCENTAJE' | 'MONTO_FIJO';
|
||||
valor: number;
|
||||
montoDescontado: number;
|
||||
};
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@ -153,7 +164,7 @@ const TripPassengerSchema = new Schema<ITripPassenger>(
|
||||
usuarioId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
required: false,
|
||||
},
|
||||
nombre: {
|
||||
type: String,
|
||||
@ -163,6 +174,10 @@ const TripPassengerSchema = new Schema<ITripPassenger>(
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
documentoIdentidad: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
direccionRecogida: {
|
||||
type: String,
|
||||
required: true,
|
||||
@ -189,6 +204,14 @@ const TripPassengerSchema = new Schema<ITripPassenger>(
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
notas: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
esTitular: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
{ _id: true }
|
||||
);
|
||||
@ -270,6 +293,12 @@ const TripSchema = new Schema<ITrip>(
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
descuentoCupon: {
|
||||
codigo: { type: String, uppercase: true },
|
||||
tipo: { type: String, enum: ['PORCENTAJE', 'MONTO_FIJO'] },
|
||||
valor: Number,
|
||||
montoDescontado: { type: Number, default: 0 },
|
||||
},
|
||||
comisionAdmin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
|
||||
@ -0,0 +1,299 @@
|
||||
import { Coupon, ICoupon, CouponDiscountType, CouponApplicableService } from '../models/Coupon';
|
||||
|
||||
export interface CreateCouponDTO {
|
||||
codigo: string;
|
||||
descripcion?: string;
|
||||
tipoDescuento: CouponDiscountType;
|
||||
valorDescuento: number;
|
||||
aplicaPara: CouponApplicableService;
|
||||
montoMinimoSubtotal?: number;
|
||||
limiteUsosTotales?: number | null;
|
||||
fechaExpiracion?: Date | string | null;
|
||||
estaActivo?: boolean;
|
||||
}
|
||||
|
||||
export interface CouponValidationResult {
|
||||
valid: boolean;
|
||||
coupon?: {
|
||||
id: string;
|
||||
codigo: string;
|
||||
descripcion: string;
|
||||
tipoDescuento: CouponDiscountType;
|
||||
valorDescuento: number;
|
||||
aplicaPara: CouponApplicableService;
|
||||
};
|
||||
subtotalOriginal: number;
|
||||
descuentoCalculado: number;
|
||||
totalFinal: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class CouponService {
|
||||
private static instance: CouponService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): CouponService {
|
||||
if (!CouponService.instance) {
|
||||
CouponService.instance = new CouponService();
|
||||
}
|
||||
return CouponService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa cupones predeterminados si la colección está vacía
|
||||
*/
|
||||
public async ensureSeedCoupons(): Promise<void> {
|
||||
try {
|
||||
const count = await Coupon.countDocuments();
|
||||
if (count === 0) {
|
||||
await Coupon.create([
|
||||
{
|
||||
codigo: 'PACIFICO10',
|
||||
descripcion: '10% de descuento en viajes de pasajeros y encomiendas',
|
||||
tipoDescuento: 'PORCENTAJE',
|
||||
valorDescuento: 10,
|
||||
aplicaPara: 'TODOS',
|
||||
montoMinimoSubtotal: 20000,
|
||||
estaActivo: true,
|
||||
},
|
||||
{
|
||||
codigo: 'BONOVIAJE5K',
|
||||
descripcion: 'Bono fijo de $5.000 COP de descuento en reserva de cupos de viaje',
|
||||
tipoDescuento: 'MONTO_FIJO',
|
||||
valorDescuento: 5000,
|
||||
aplicaPara: 'VIAJES',
|
||||
montoMinimoSubtotal: 35000,
|
||||
estaActivo: true,
|
||||
},
|
||||
{
|
||||
codigo: 'ENVIOYA',
|
||||
descripcion: 'Bono de $4.000 COP de descuento en fletes de mensajería y paquetería',
|
||||
tipoDescuento: 'MONTO_FIJO',
|
||||
valorDescuento: 4000,
|
||||
aplicaPara: 'ENCOMIENDAS',
|
||||
montoMinimoSubtotal: 25000,
|
||||
estaActivo: true,
|
||||
},
|
||||
{
|
||||
codigo: 'PROMO20',
|
||||
descripcion: 'Super Bono del 20% de descuento especial',
|
||||
tipoDescuento: 'PORCENTAJE',
|
||||
valorDescuento: 20,
|
||||
aplicaPara: 'TODOS',
|
||||
montoMinimoSubtotal: 50000,
|
||||
estaActivo: true,
|
||||
},
|
||||
]);
|
||||
console.log('🎟️ [Coupons Seeded] Cupones iniciales creados exitosamente en MongoDB.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('⚠️ [Coupons Seed Warning]:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los cupones para el panel de administración
|
||||
*/
|
||||
public async getAllCoupons(): Promise<ICoupon[]> {
|
||||
await this.ensureSeedCoupons();
|
||||
return Coupon.find().sort({ createdAt: -1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo cupón desde el panel administrativo
|
||||
*/
|
||||
public async createCoupon(data: CreateCouponDTO): Promise<ICoupon> {
|
||||
const cleanCode = (data.codigo || '').trim().toUpperCase();
|
||||
if (!cleanCode) {
|
||||
throw new Error('El código del cupón es obligatorio.');
|
||||
}
|
||||
|
||||
const existing = await Coupon.findOne({ codigo: cleanCode });
|
||||
if (existing) {
|
||||
throw new Error(`El cupón con código "${cleanCode}" ya existe.`);
|
||||
}
|
||||
|
||||
const valor = parseFloat(String(data.valorDescuento));
|
||||
if (isNaN(valor) || valor <= 0) {
|
||||
throw new Error('El valor del descuento debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
if (data.tipoDescuento === 'PORCENTAJE' && valor > 100) {
|
||||
throw new Error('El porcentaje de descuento no puede ser mayor al 100%.');
|
||||
}
|
||||
|
||||
let expDate: Date | null = null;
|
||||
if (data.fechaExpiracion) {
|
||||
expDate = new Date(data.fechaExpiracion);
|
||||
}
|
||||
|
||||
const coupon = await Coupon.create({
|
||||
codigo: cleanCode,
|
||||
descripcion: (data.descripcion || '').trim(),
|
||||
tipoDescuento: data.tipoDescuento || 'PORCENTAJE',
|
||||
valorDescuento: valor,
|
||||
aplicaPara: data.aplicaPara || 'TODOS',
|
||||
montoMinimoSubtotal: data.montoMinimoSubtotal ? Math.max(0, parseFloat(String(data.montoMinimoSubtotal))) : 0,
|
||||
limiteUsosTotales: data.limiteUsosTotales ? parseInt(String(data.limiteUsosTotales), 10) : null,
|
||||
fechaExpiracion: expDate,
|
||||
estaActivo: data.estaActivo !== undefined ? data.estaActivo : true,
|
||||
});
|
||||
|
||||
console.log(`🎟️ [Coupon Created] Cupón creado: ${coupon.codigo} (${coupon.tipoDescuento}: ${coupon.valorDescuento})`);
|
||||
return coupon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida un cupón de descuento para un servicio y monto determinados
|
||||
*/
|
||||
public async validateCoupon(
|
||||
code: string,
|
||||
serviceType: 'VIAJES' | 'ENCOMIENDAS',
|
||||
subtotal: number
|
||||
): Promise<CouponValidationResult> {
|
||||
await this.ensureSeedCoupons();
|
||||
|
||||
const cleanCode = (code || '').trim().toUpperCase();
|
||||
if (!cleanCode) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: 'Por favor ingresa un código de cupón.',
|
||||
};
|
||||
}
|
||||
|
||||
const coupon = await Coupon.findOne({ codigo: cleanCode });
|
||||
if (!coupon) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `El cupón "${cleanCode}" no existe o no es válido.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!coupon.estaActivo) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `El cupón "${cleanCode}" se encuentra actualmente inactivo.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (coupon.fechaExpiracion && new Date() > new Date(coupon.fechaExpiracion)) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `El cupón "${cleanCode}" ha expirado el ${coupon.fechaExpiracion.toLocaleDateString('es-CO')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (coupon.limiteUsosTotales && coupon.canjesActuales >= coupon.limiteUsosTotales) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `El cupón "${cleanCode}" ha alcanzado su límite máximo de canjes disponibles.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (coupon.aplicaPara !== 'TODOS' && coupon.aplicaPara !== serviceType) {
|
||||
const servicioPermitido = coupon.aplicaPara === 'VIAJES' ? 'viajes de pasajeros' : 'encomiendas y fletes';
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `Este cupón solo es válido para ${servicioPermitido}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (coupon.montoMinimoSubtotal > 0 && subtotal < coupon.montoMinimoSubtotal) {
|
||||
return {
|
||||
valid: false,
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: 0,
|
||||
totalFinal: subtotal,
|
||||
message: `Este cupón requiere un subtotal mínimo de $${coupon.montoMinimoSubtotal.toLocaleString('es-CO')} COP.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Cálculo del descuento
|
||||
let descuento = 0;
|
||||
if (coupon.tipoDescuento === 'PORCENTAJE') {
|
||||
descuento = Math.round(subtotal * (coupon.valorDescuento / 100));
|
||||
} else {
|
||||
descuento = Math.min(coupon.valorDescuento, subtotal);
|
||||
}
|
||||
|
||||
// Evitar que el total quede menor a cero
|
||||
descuento = Math.min(descuento, subtotal);
|
||||
const totalFinal = Math.max(0, subtotal - descuento);
|
||||
|
||||
const descTexto = coupon.tipoDescuento === 'PORCENTAJE'
|
||||
? `${coupon.valorDescuento}%`
|
||||
: `$${coupon.valorDescuento.toLocaleString('es-CO')} COP`;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
coupon: {
|
||||
id: coupon._id.toString(),
|
||||
codigo: coupon.codigo,
|
||||
descripcion: coupon.descripcion,
|
||||
tipoDescuento: coupon.tipoDescuento,
|
||||
valorDescuento: coupon.valorDescuento,
|
||||
aplicaPara: coupon.aplicaPara,
|
||||
},
|
||||
subtotalOriginal: subtotal,
|
||||
descuentoCalculado: descuento,
|
||||
totalFinal,
|
||||
message: `✅ ¡Cupón "${coupon.codigo}" aplicado con éxito! Ahorro: $${descuento.toLocaleString('es-CO')} COP (${descTexto} OFF).`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canjea formalmente el cupón al crearse un viaje o encomienda
|
||||
*/
|
||||
public async redeemCoupon(code: string): Promise<boolean> {
|
||||
const cleanCode = (code || '').trim().toUpperCase();
|
||||
if (!cleanCode) return false;
|
||||
|
||||
const res = await Coupon.findOneAndUpdate(
|
||||
{ codigo: cleanCode, estaActivo: true },
|
||||
{ $inc: { canjesActuales: 1 } },
|
||||
{ new: true }
|
||||
);
|
||||
return !!res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alterna el estado activo/inactivo de un cupón
|
||||
*/
|
||||
public async toggleCouponStatus(id: string, estaActivo?: boolean): Promise<ICoupon | null> {
|
||||
const coupon = await Coupon.findById(id);
|
||||
if (!coupon) return null;
|
||||
|
||||
coupon.estaActivo = estaActivo !== undefined ? estaActivo : !coupon.estaActivo;
|
||||
await coupon.save();
|
||||
return coupon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un cupón del sistema
|
||||
*/
|
||||
public async deleteCoupon(id: string): Promise<boolean> {
|
||||
const res = await Coupon.findByIdAndDelete(id);
|
||||
return !!res;
|
||||
}
|
||||
}
|
||||
|
||||
export const couponService = CouponService.getInstance();
|
||||
@ -3,6 +3,7 @@ import { User } from '../models/User';
|
||||
import { Trip, ITrip, IPackageInfo } from '../models/Trip';
|
||||
import { TarifasConfig, ITarifasConfig } from '../models/TarifasConfig';
|
||||
import { boldPaymentService, BoldPaymentResponse } from './bold.service';
|
||||
import { couponService } from './coupon.service';
|
||||
|
||||
export interface PackageQuoteRequest {
|
||||
remitenteId: string;
|
||||
@ -23,6 +24,7 @@ export interface PackageQuoteRequest {
|
||||
descripcion: string;
|
||||
pesoAproxKg?: number;
|
||||
incluirSeguro?: boolean;
|
||||
codigoCupon?: string;
|
||||
}
|
||||
|
||||
export interface PackageFareBreakdown {
|
||||
@ -33,6 +35,13 @@ export interface PackageFareBreakdown {
|
||||
factorBulto: number;
|
||||
tipoBulto: string;
|
||||
valorSeguro: number;
|
||||
subtotalOriginal?: number;
|
||||
descuentoCupon?: {
|
||||
codigo: string;
|
||||
tipo: string;
|
||||
valor: number;
|
||||
montoDescontado: number;
|
||||
};
|
||||
tarifaTotal: number;
|
||||
porcentajeComisionAdmin: number;
|
||||
comisionAdmin: number;
|
||||
@ -152,6 +161,34 @@ export class MessagingFleteService {
|
||||
const distanciaKm = this.calcularDistanciaKm(request.coordenadasRecogida, request.coordenadasEntrega);
|
||||
const desglose = await this.cotizarFlete(distanciaKm, request.tipoBulto, request.incluirSeguro ?? true);
|
||||
|
||||
const subtotalBruto = desglose.tarifaTotal;
|
||||
let descuentoTotal = 0;
|
||||
let cuponDescInfo: any = null;
|
||||
|
||||
if (request.codigoCupon) {
|
||||
try {
|
||||
const valCup = await couponService.validateCoupon(request.codigoCupon, 'ENCOMIENDAS', subtotalBruto);
|
||||
if (valCup.valid && valCup.descuentoCalculado > 0) {
|
||||
descuentoTotal = valCup.descuentoCalculado;
|
||||
cuponDescInfo = {
|
||||
codigo: valCup.coupon!.codigo,
|
||||
tipo: valCup.coupon!.tipoDescuento,
|
||||
valor: valCup.coupon!.valorDescuento,
|
||||
montoDescontado: descuentoTotal,
|
||||
};
|
||||
desglose.subtotalOriginal = subtotalBruto;
|
||||
desglose.descuentoCupon = cuponDescInfo;
|
||||
desglose.tarifaTotal = Math.max(0, subtotalBruto - descuentoTotal);
|
||||
desglose.comisionAdmin = Math.round(desglose.tarifaTotal * (desglose.porcentajeComisionAdmin / 100));
|
||||
desglose.pagoConductor = desglose.tarifaTotal - desglose.comisionAdmin;
|
||||
await couponService.redeemCoupon(valCup.coupon!.codigo);
|
||||
console.log(`🎟️ [Cupón Aplicado en Encomienda] Código: ${valCup.coupon!.codigo}, Descuento: $${descuentoTotal} COP`);
|
||||
}
|
||||
} catch (errCup) {
|
||||
console.warn('⚠️ [Error validando cupón en flete]:', errCup);
|
||||
}
|
||||
}
|
||||
|
||||
const paqueteInfo: IPackageInfo = {
|
||||
remitente: {
|
||||
nombre: request.remitenteNombre,
|
||||
@ -196,6 +233,7 @@ export class MessagingFleteService {
|
||||
estado: 'PENDING_APPROVAL',
|
||||
metodoPago: 'BOLD',
|
||||
tarifaTotal: desglose.tarifaTotal,
|
||||
descuentoCupon: cuponDescInfo || undefined,
|
||||
comisionAdmin: desglose.comisionAdmin,
|
||||
pagoConductor: desglose.pagoConductor,
|
||||
horaEstimadaRecogida: new Date(Date.now() + 60 * 60 * 1000), // En 1 hora
|
||||
@ -225,7 +263,7 @@ export class MessagingFleteService {
|
||||
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}`,
|
||||
description: `Flete Encomienda Cali-Buenaventura #${trip._id.toString().slice(-6)} - Bulto: ${request.tipoBulto}${cuponDescInfo ? ` (Bono ${cuponDescInfo.codigo})` : ''}`,
|
||||
payerEmail: request.remitenteEmail || remitente.email,
|
||||
payerPhone: request.remitenteTelefono || remitente.telefono,
|
||||
paymentType: 'MESSAGING_PACKAGE',
|
||||
|
||||
@ -4,9 +4,21 @@ import { Trip, ITrip, ITripPassenger, IWazeWaypoint } from '../models/Trip';
|
||||
import { TarifasConfig } from '../models/TarifasConfig';
|
||||
import { DriverProfile } from '../models/DriverProfile';
|
||||
import { whatsAppNotificationService } from './whatsapp-notification.service';
|
||||
import { boldPaymentService } from './bold.service';
|
||||
import { couponService } from './coupon.service';
|
||||
|
||||
export interface PassengerItemInput {
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
documentoIdentidad?: string;
|
||||
direccionRecogida?: string;
|
||||
coordenadas?: [number, number]; // [lng, lat]
|
||||
notas?: string;
|
||||
esTitular?: boolean;
|
||||
}
|
||||
|
||||
export interface PassengerBookingRequest {
|
||||
usuarioId: string;
|
||||
usuarioId?: string;
|
||||
direccionTexto: string;
|
||||
coordenadas: [number, number]; // [lng, lat]
|
||||
sentido: 'CALI_A_BUENAVENTURA' | 'BUENAVENTURA_A_CALI';
|
||||
@ -14,7 +26,11 @@ export interface PassengerBookingRequest {
|
||||
nombre: string;
|
||||
coordenadas: [number, number]; // [lng, lat]
|
||||
};
|
||||
metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO';
|
||||
metodoPago: 'BOLD' | 'NEQUI' | 'EFECTIVO' | 'BRE_B';
|
||||
cuposSolicitados?: number;
|
||||
pasajeros?: PassengerItemInput[];
|
||||
email?: string;
|
||||
codigoCupon?: string;
|
||||
}
|
||||
|
||||
export interface PoolingResult {
|
||||
@ -22,9 +38,14 @@ export interface PoolingResult {
|
||||
message: string;
|
||||
trip?: ITrip;
|
||||
tripId?: string;
|
||||
cuposReservados?: number;
|
||||
totalCuposOcupados?: number;
|
||||
pasajerosAgrupados?: number;
|
||||
secuenciaParadas?: IWazeWaypoint[];
|
||||
estado: string;
|
||||
boldPayment?: any;
|
||||
descuentoAplicado?: number;
|
||||
codigoCupon?: string;
|
||||
motivoRechazo?: string;
|
||||
}
|
||||
|
||||
@ -41,49 +62,86 @@ export class PoolingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa una nueva solicitud de pasajero y ejecuta el algoritmo de pooling geoespacial con $near
|
||||
* Procesa una nueva solicitud de reserva de cupos (1 a 4 pasajeros) y ejecuta el algoritmo de pooling geoespacial
|
||||
*/
|
||||
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) {
|
||||
// 1. Extraer y normalizar los pasajeros solicitados
|
||||
const rawPasajeros = (request.pasajeros && request.pasajeros.length > 0)
|
||||
? request.pasajeros
|
||||
: [{
|
||||
nombre: 'Pasajero Principal',
|
||||
telefono: '3000000000',
|
||||
documentoIdentidad: '',
|
||||
direccionRecogida: request.direccionTexto,
|
||||
coordenadas: request.coordenadas,
|
||||
notas: '',
|
||||
esTitular: true,
|
||||
}];
|
||||
|
||||
const numPasajerosNuevos = rawPasajeros.length;
|
||||
|
||||
if (numPasajerosNuevos < 1 || numPasajerosNuevos > 4) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'El usuario solicitante no existe en el sistema.',
|
||||
message: 'La cantidad de cupos a reservar debe ser entre 1 y 4 pasajeros.',
|
||||
estado: 'REJECTED',
|
||||
motivoRechazo: 'USUARIO_INEXISTENTE',
|
||||
motivoRechazo: 'CUPOS_INVALIDOS',
|
||||
};
|
||||
}
|
||||
|
||||
if (user.saldoPenalidad > 0) {
|
||||
// 2. Identificar o registrar al usuario titular
|
||||
let user: any = null;
|
||||
if (request.usuarioId && Types.ObjectId.isValid(request.usuarioId)) {
|
||||
user = await User.findById(request.usuarioId);
|
||||
}
|
||||
|
||||
const primerPasajero = rawPasajeros[0];
|
||||
const cleanPhone = (primerPasajero.telefono || '').replace(/\D/g, '');
|
||||
|
||||
if (!user && cleanPhone) {
|
||||
user = await User.findOne({ telefono: { $regex: cleanPhone.slice(-10) } });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
nombre: primerPasajero.nombre?.trim() || 'Pasajero Titular',
|
||||
telefono: cleanPhone || '573000000000',
|
||||
email: request.email ? request.email.trim().toLowerCase() : undefined,
|
||||
rol: 'PASAJERO',
|
||||
estaActivo: true,
|
||||
saldoPenalidad: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (user && 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.`,
|
||||
message: `El usuario titular 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
|
||||
// 3. 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)
|
||||
// 4. Buscar si existe un Trip en estado PENDING_APPROVAL en la misma ruta con cupos disponibles para TODOS los pasajeros nuevos
|
||||
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] },
|
||||
$expr: {
|
||||
$lte: [{ $add: [{ $size: '$pasajeros' }, numPasajerosNuevos] }, 4],
|
||||
},
|
||||
'origen.ubicacion': {
|
||||
$near: {
|
||||
$geometry: {
|
||||
@ -95,14 +153,15 @@ export class PoolingService {
|
||||
},
|
||||
});
|
||||
} 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] },
|
||||
$expr: {
|
||||
$lte: [{ $add: [{ $size: '$pasajeros' }, numPasajerosNuevos] }, 4],
|
||||
},
|
||||
'destino.ubicacion': {
|
||||
$near: {
|
||||
$geometry: {
|
||||
@ -115,31 +174,45 @@ export class PoolingService {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
const paymentStatus =
|
||||
request.metodoPago === 'BOLD'
|
||||
? 'PAGADO_BOLD'
|
||||
: request.metodoPago === 'NEQUI'
|
||||
? 'PAGADO_NEQUI'
|
||||
: request.metodoPago === 'BRE_B'
|
||||
? 'PAGADO_BRE_B'
|
||||
: 'PENDIENTE_EFECTIVO';
|
||||
|
||||
// Construir la lista de nuevos pasajeros con sus datos individuales
|
||||
const nuevosPasajerosItems: ITripPassenger[] = rawPasajeros.map((p, idx) => {
|
||||
const pLng = (p.coordenadas && p.coordenadas.length === 2 && !isNaN(p.coordenadas[0])) ? p.coordenadas[0] : reqLng;
|
||||
const pLat = (p.coordenadas && p.coordenadas.length === 2 && !isNaN(p.coordenadas[1])) ? p.coordenadas[1] : reqLat;
|
||||
const pDir = p.direccionRecogida?.trim() || request.direccionTexto;
|
||||
|
||||
return {
|
||||
usuarioId: user._id,
|
||||
nombre: p.nombre?.trim() || (idx === 0 ? user.nombre : `Acompañante ${idx + 1}`),
|
||||
telefono: p.telefono?.trim() || user.telefono,
|
||||
documentoIdentidad: p.documentoIdentidad?.trim() || '',
|
||||
direccionRecogida: pDir,
|
||||
coordenadas: {
|
||||
type: 'Point',
|
||||
coordinates: [pLng, pLat],
|
||||
},
|
||||
estadoPago: paymentStatus,
|
||||
valor: tarifaPorPasajero,
|
||||
secuenciaOrden: idx + 1,
|
||||
recogido: false,
|
||||
notas: p.notas?.trim() || '',
|
||||
esTitular: idx === 0 || !!p.esTitular,
|
||||
};
|
||||
});
|
||||
|
||||
let targetTrip: ITrip;
|
||||
|
||||
if (candidateTrip) {
|
||||
// Agrupar al pasajero en el viaje existente
|
||||
candidateTrip.pasajeros.push(nuevoPasajeroItem);
|
||||
// Agrupar todos los nuevos pasajeros en el viaje existente
|
||||
candidateTrip.pasajeros.push(...nuevosPasajerosItems);
|
||||
targetTrip = candidateTrip;
|
||||
} else {
|
||||
// Crear un nuevo grupo de viaje en estado PENDING_APPROVAL
|
||||
@ -169,14 +242,14 @@ export class PoolingService {
|
||||
esPuntoFijo: request.sentido === 'CALI_A_BUENAVENTURA',
|
||||
ciudad: request.sentido === 'CALI_A_BUENAVENTURA' ? 'BUENAVENTURA' : 'CALI',
|
||||
},
|
||||
pasajeros: [nuevoPasajeroItem],
|
||||
pasajeros: nuevosPasajerosItems,
|
||||
estado: 'PENDING_APPROVAL',
|
||||
metodoPago: request.metodoPago,
|
||||
horaEstimadaRecogida: horaRecogida,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Calcular optimización de ruta y secuencia Waze parada por parada (Nearest Neighbor TSP)
|
||||
// 5. 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,
|
||||
@ -193,8 +266,7 @@ export class PoolingService {
|
||||
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
|
||||
// Construir deep links oficiales de Waze para cada parada
|
||||
const paradasWaze: IWazeWaypoint[] = secuenciaOptimizada.map((punto, step) => {
|
||||
return {
|
||||
orden: step + 1,
|
||||
@ -209,9 +281,34 @@ export class PoolingService {
|
||||
|
||||
targetTrip.secuenciaRutaWaze = paradasWaze;
|
||||
|
||||
// 5. Recalcular métricas financieras
|
||||
// 6. Recalcular métricas financieras y aplicar cupón de descuento si existe
|
||||
const subtotalReserva = numPasajerosNuevos * tarifaPorPasajero;
|
||||
let descuentoTotal = 0;
|
||||
let cuponAplicadoCodigo = '';
|
||||
|
||||
if (request.codigoCupon) {
|
||||
try {
|
||||
const valCup = await couponService.validateCoupon(request.codigoCupon, 'VIAJES', subtotalReserva);
|
||||
if (valCup.valid && valCup.descuentoCalculado > 0) {
|
||||
descuentoTotal = valCup.descuentoCalculado;
|
||||
cuponAplicadoCodigo = valCup.coupon!.codigo;
|
||||
targetTrip.descuentoCupon = {
|
||||
codigo: valCup.coupon!.codigo,
|
||||
tipo: valCup.coupon!.tipoDescuento,
|
||||
valor: valCup.coupon!.valorDescuento,
|
||||
montoDescontado: descuentoTotal,
|
||||
};
|
||||
await couponService.redeemCoupon(valCup.coupon!.codigo);
|
||||
console.log(`🎟️ [Cupón Aplicado en Viaje] Código: ${valCup.coupon!.codigo}, Descuento: $${descuentoTotal} COP`);
|
||||
}
|
||||
} catch (errCup) {
|
||||
console.warn('⚠️ [Error validando cupón en pooling]:', errCup);
|
||||
}
|
||||
}
|
||||
|
||||
const cantidadPasajeros = targetTrip.pasajeros.length;
|
||||
targetTrip.tarifaTotal = cantidadPasajeros * tarifaPorPasajero;
|
||||
const tarifaBruta = cantidadPasajeros * tarifaPorPasajero;
|
||||
targetTrip.tarifaTotal = Math.max(0, tarifaBruta - descuentoTotal);
|
||||
targetTrip.comisionAdmin = Math.round(targetTrip.tarifaTotal * (config.porcentajeComisionAdmin / 100));
|
||||
targetTrip.pagoConductor = targetTrip.tarifaTotal - targetTrip.comisionAdmin;
|
||||
|
||||
@ -222,11 +319,36 @@ export class PoolingService {
|
||||
targetTrip.estado = 'POOL_COMPLETED';
|
||||
}
|
||||
|
||||
// 7. Generar cobro con Pasarela Bold si el método seleccionado es BOLD
|
||||
let boldPaymentResult: any = null;
|
||||
if (request.metodoPago === 'BOLD') {
|
||||
const montoReservaCobrar = Math.max(0, subtotalReserva - descuentoTotal);
|
||||
try {
|
||||
const boldRes = await boldPaymentService.createPayment({
|
||||
referenceId: targetTrip._id.toString(),
|
||||
amount: montoReservaCobrar,
|
||||
description: `Reserva ${numPasajerosNuevos} Cupo(s) Cali-Buenaventura #${targetTrip._id.toString().slice(-6)}${cuponAplicadoCodigo ? ` (Bono ${cuponAplicadoCodigo})` : ''}`,
|
||||
payerEmail: request.email || user.email,
|
||||
payerPhone: primerPasajero.telefono || user.telefono,
|
||||
paymentType: 'TRIP_PASSENGER',
|
||||
});
|
||||
if (boldRes.success) {
|
||||
boldPaymentResult = boldRes;
|
||||
targetTrip.boldPayment = {
|
||||
paymentLinkId: boldRes.paymentLinkId,
|
||||
checkoutUrl: boldRes.checkoutUrl,
|
||||
qrImageUrl: boldRes.qrImageUrl,
|
||||
transactionStatus: 'ACTIVE',
|
||||
};
|
||||
}
|
||||
} catch (errBold) {
|
||||
console.warn('⚠️ [Bold Gateway Notice]:', errBold);
|
||||
}
|
||||
}
|
||||
|
||||
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.`);
|
||||
|
||||
@ -260,7 +382,6 @@ export class PoolingService {
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
@ -269,16 +390,23 @@ export class PoolingService {
|
||||
});
|
||||
}
|
||||
|
||||
const pluralCupos = numPasajerosNuevos > 1 ? `${numPasajerosNuevos} cupos reservados` : '1 cupo reservado';
|
||||
|
||||
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.`,
|
||||
? `¡Vehículo Completo (4/4 pasajeros)! Se reservaron ${pluralCupos}. Viaje listo para asignación de conductor.`
|
||||
: `¡${pluralCupos.toUpperCase()} CON ÉXITO! Ocupación actual: (${cantidadPasajeros}/4 cupos).`,
|
||||
trip: targetTrip,
|
||||
tripId: targetTrip._id.toString(),
|
||||
cuposReservados: numPasajerosNuevos,
|
||||
totalCuposOcupados: cantidadPasajeros,
|
||||
pasajerosAgrupados: cantidadPasajeros,
|
||||
secuenciaParadas: paradasWaze,
|
||||
estado: targetTrip.estado,
|
||||
boldPayment: boldPaymentResult,
|
||||
descuentoAplicado: descuentoTotal,
|
||||
codigoCupon: cuponAplicadoCodigo || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -11,12 +11,15 @@ export interface PassengerPaymentSummary {
|
||||
pasajeroId: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
documentoIdentidad?: string;
|
||||
direccionRecogida: string;
|
||||
estadoPago: PassengerPaymentStatus;
|
||||
valor: number;
|
||||
esRecaudadoPlataforma: boolean;
|
||||
esRecaudadoEfectivo: boolean;
|
||||
esPendienteEfectivo: boolean;
|
||||
notas?: string;
|
||||
esTitular?: boolean;
|
||||
}
|
||||
|
||||
export interface TripFinancialSummary {
|
||||
@ -145,12 +148,15 @@ export class SettlementService {
|
||||
pasajeroId: p._id?.toString() || p.usuarioId?.toString(),
|
||||
nombre: p.nombre,
|
||||
telefono: p.telefono,
|
||||
documentoIdentidad: p.documentoIdentidad || '',
|
||||
direccionRecogida: p.direccionRecogida,
|
||||
estadoPago: p.estadoPago,
|
||||
valor,
|
||||
esRecaudadoPlataforma: esPlataforma,
|
||||
esRecaudadoEfectivo,
|
||||
esPendienteEfectivo,
|
||||
notas: p.notas || '',
|
||||
esTitular: p.esTitular || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user