diff --git a/server.js b/server.js index 48530e6..130d4ad 100644 --- a/server.js +++ b/server.js @@ -248,6 +248,20 @@ async function autoUpdateDB() { await client.query(` DO $$ BEGIN + -- AÑADIDO: Token mágico para el Portal del Cliente + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='clients' AND column_name='portal_token') THEN + ALTER TABLE clients ADD COLUMN portal_token TEXT UNIQUE; + UPDATE clients SET portal_token = substr(md5(random()::text || id::text), 1, 12) WHERE portal_token IS NULL; + ALTER TABLE clients ALTER COLUMN portal_token SET DEFAULT substr(md5(random()::text || clock_timestamp()::text), 1, 12); + END IF; + + -- AÑADIDO: Motor de Ranking + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='ranking_score') THEN + ALTER TABLE users ADD COLUMN ranking_score NUMERIC DEFAULT 50.0; + ALTER TABLE users ADD COLUMN ranking_data JSONB DEFAULT '{}'::jsonb; + END IF; + + -- NUEVO: Columna para colores personalizados de la App IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='app_settings') THEN ALTER TABLE users ADD COLUMN app_settings JSONB DEFAULT '{"primary": "#1e3a8a", "secondary": "#2563eb", "bg": "#f8fafc"}'; @@ -1645,6 +1659,130 @@ app.put("/guilds/:id/ia-rules", authMiddleware, async (req, res) => { } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); +// ========================================== +// 🏆 MOTOR DE RANKING Y ESTADÍSTICAS +// ========================================== + +// Función interna que calcula el algoritmo exacto sobre 100 +function calculateScore(services) { + let score = 0; + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000)); + + // Separar servicios + const openServices = services.filter(s => !s.is_final); + const closedLast30Days = services.filter(s => s.is_final && new Date(s.updated_at) >= thirtyDaysAgo); + + // --- 1. VELOCIDAD DE CIERRE (Max 30 Puntos) --- + let scoreCierre = 0; + if (closedLast30Days.length > 0) { + let totalDaysToClose = 0; + closedLast30Days.forEach(s => { + const created = new Date(s.created_at); + const closed = new Date(s.updated_at); + totalDaysToClose += (closed - created) / (1000 * 60 * 60 * 24); + }); + const avgCloseDays = totalDaysToClose / closedLast30Days.length; + + // Algoritmo: <= 2 días = 30 pts. >= 14 días = 0 pts. + if (avgCloseDays <= 2) scoreCierre = 30; + else if (avgCloseDays >= 14) scoreCierre = 0; + else scoreCierre = 30 - ((avgCloseDays - 2) * (30 / 12)); + } + + // --- 2. CITA RÁPIDA < 24h (Max 30 Puntos) --- + let scoreCita = 0; + let validSchedules = 0; + let fastSchedules = 0; + + const recentServices = services.filter(s => new Date(s.created_at) >= thirtyDaysAgo); + recentServices.forEach(s => { + const raw = s.raw_data || {}; + if (raw.scheduled_date) { + validSchedules++; + const created = new Date(s.created_at); + const [y, m, d] = raw.scheduled_date.split('-'); + const schedDate = new Date(y, m - 1, d); + + const diffDays = (schedDate - created) / (1000 * 60 * 60 * 24); + if (diffDays <= 1.5) fastSchedules++; + } + }); + + if (validSchedules > 0) { + const fastRatio = fastSchedules / validSchedules; + scoreCita = fastRatio * 30; + } + + // --- 3. VOLUMEN DE TRABAJO AL DÍA (Max 20 Puntos) --- + let scoreVolumen = 0; + const closedPerDay = closedLast30Days.length / 22; + + if (closedPerDay >= 3) scoreVolumen = 20; + else scoreVolumen = (closedPerDay / 3) * 20; + + // --- 4. PENALIZACIÓN POR RE-CITAS (Max 20 Puntos) --- + let scoreRecitas = 20; + let totalCalled = 0; + recentServices.forEach(s => { + const raw = s.raw_data || {}; + const calls = parseInt(raw.called_times || 0); + if (calls > 1) totalCalled += (calls - 1); + }); + + scoreRecitas = Math.max(0, 20 - (totalCalled * 2)); + + // --- SUMA BASE --- + let totalScore = scoreCierre + scoreCita + scoreVolumen + scoreRecitas; + + // --- 5. PENALIZACIÓN POR ACUMULACIÓN DE ABIERTOS --- + let penalizacionAbiertos = 0; + if (openServices.length > 15) { + penalizacionAbiertos = (openServices.length - 15) * 1.5; + } + + totalScore -= penalizacionAbiertos; + totalScore = Math.min(100, Math.max(0, totalScore)); + + return { + score: Math.round(totalScore), + details: { + cierre: Math.round(scoreCierre), + cita: Math.round(scoreCita), + volumen: Math.round(scoreVolumen), + recitas: Math.round(scoreRecitas), + penalizacion: Math.round(penalizacionAbiertos), + abiertos: openServices.length, + cerrados_mes: closedLast30Days.length + } + }; +} + +// RUTA GET PARA EL RANKING +app.get("/ranking", authMiddleware, async (req, res) => { + try { + const q = await pool.query(` + SELECT id, created_at, updated_at, raw_data, + (SELECT is_final FROM service_statuses WHERE id::text = raw_data->>'status_operativo') as is_final + FROM scraped_services + WHERE assigned_to = $1 + `, [req.user.sub]); + + const rankingData = calculateScore(q.rows); + + await pool.query( + "UPDATE users SET ranking_score = $1, ranking_data = $2 WHERE id = $3", + [rankingData.score, rankingData.details, req.user.sub] + ); + + res.json({ ok: true, ranking: rankingData }); + + } catch (error) { + console.error("Error en ranking:", error); + res.status(500).json({ ok: false }); + } +}); + // ========================================== // 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto) // ==========================================