Actualizar server.js
This commit is contained in:
138
server.js
138
server.js
@@ -248,6 +248,20 @@ async function autoUpdateDB() {
|
|||||||
await client.query(`
|
await client.query(`
|
||||||
DO $$ BEGIN
|
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
|
-- 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
|
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"}';
|
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 }); }
|
} 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)
|
// 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
Reference in New Issue
Block a user