Actualizar server.js
This commit is contained in:
443
server.js
443
server.js
@@ -497,37 +497,88 @@ function genCode6() { return String(Math.floor(100000 + Math.random() * 900000))
|
||||
// 🛡️ ESCUDO DE TITANIO: ANTI-SOLAPAMIENTOS BBDD
|
||||
// ==========================================
|
||||
async function comprobarDisponibilidad(ownerId, workerId, date, time, durationMin, excludeId = null) {
|
||||
if (!workerId || !date || !time) return { choca: false };
|
||||
try {
|
||||
if (!ownerId || !workerId || !date || !time) {
|
||||
return { choca: false };
|
||||
}
|
||||
|
||||
let [newH, newM] = time.split(':').map(Number);
|
||||
let newStart = newH * 60 + newM;
|
||||
let newEnd = newStart + (parseInt(durationMin) || 60);
|
||||
const parseTimeToMinutes = (value) => {
|
||||
if (!value) return null;
|
||||
const parts = String(value).trim().split(":");
|
||||
if (parts.length < 2) return null;
|
||||
const hh = parseInt(parts[0], 10);
|
||||
const mm = parseInt(parts[1], 10);
|
||||
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
|
||||
return (hh * 60) + mm;
|
||||
};
|
||||
|
||||
const requestedStart = parseTimeToMinutes(time);
|
||||
const requestedDuration = Math.max(parseInt(durationMin, 10) || 60, 1);
|
||||
|
||||
if (requestedStart === null) {
|
||||
return { choca: false };
|
||||
}
|
||||
|
||||
const requestedEnd = requestedStart + requestedDuration;
|
||||
|
||||
let query = `
|
||||
SELECT id, service_ref, raw_data->>'scheduled_time' as time, raw_data->>'duration_minutes' as dur,
|
||||
raw_data->>'requested_time' as req_time, raw_data->>'appointment_status' as app_status
|
||||
SELECT
|
||||
id,
|
||||
service_ref,
|
||||
COALESCE(
|
||||
NULLIF(raw_data->>'scheduled_time', ''),
|
||||
CASE
|
||||
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '')
|
||||
ELSE NULL
|
||||
END
|
||||
) as effective_time,
|
||||
COALESCE(
|
||||
NULLIF(raw_data->>'duration_minutes', ''),
|
||||
'60'
|
||||
) as effective_duration
|
||||
FROM scraped_services
|
||||
WHERE owner_id = $1 AND assigned_to = $2 AND status != 'archived'
|
||||
WHERE owner_id = $1
|
||||
AND assigned_to = $2
|
||||
AND status != 'archived'
|
||||
AND (
|
||||
raw_data->>'scheduled_date' = $3 OR
|
||||
(raw_data->>'requested_date' = $3 AND raw_data->>'appointment_status' = 'pending')
|
||||
raw_data->>'scheduled_date' = $3
|
||||
OR (
|
||||
raw_data->>'appointment_status' = 'pending'
|
||||
AND raw_data->>'requested_date' = $3
|
||||
)
|
||||
)
|
||||
`;
|
||||
let params = [ownerId, workerId, date];
|
||||
if (excludeId) { query += ` AND id != $4`; params.push(excludeId); }
|
||||
|
||||
const params = [ownerId, workerId, date];
|
||||
|
||||
if (excludeId) {
|
||||
query += ` AND id != $4`;
|
||||
params.push(excludeId);
|
||||
}
|
||||
|
||||
const q = await pool.query(query, params);
|
||||
|
||||
for (const s of q.rows) {
|
||||
let sTime = s.time || (s.app_status === 'pending' ? s.req_time : null);
|
||||
if (sTime && sTime.includes(':')) {
|
||||
let [sH, sM] = sTime.split(':').map(Number);
|
||||
let sStart = sH * 60 + sM;
|
||||
let sEnd = sStart + parseInt(s.dur || 60);
|
||||
if (newStart < sEnd && newEnd > sStart) return { choca: true, ref: s.service_ref, time: sTime };
|
||||
for (const row of q.rows) {
|
||||
const start = parseTimeToMinutes(row.effective_time);
|
||||
if (start === null) continue;
|
||||
|
||||
const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1);
|
||||
const end = start + duration;
|
||||
|
||||
if (requestedStart < end && requestedEnd > start) {
|
||||
return {
|
||||
choca: true,
|
||||
ref: row.service_ref || `ID-${row.id}`,
|
||||
time: row.effective_time
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { choca: false };
|
||||
} catch (e) {
|
||||
console.error("❌ Error en comprobarDisponibilidad:", e.message);
|
||||
return { choca: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -938,8 +989,215 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
|
||||
day: "numeric"
|
||||
}).format(new Date());
|
||||
|
||||
// Historial REAL del chat (ya incluye el último mensaje del cliente
|
||||
// si el webhook lo ha guardado antes de llamar a esta función)
|
||||
// =========================================================
|
||||
// HELPERS INTERNOS (AUTOCONTENIDOS, PARA NO ROMPER TU SERVER)
|
||||
// =========================================================
|
||||
const parseTimeToMinutes = (value) => {
|
||||
if (!value) return null;
|
||||
const parts = String(value).trim().split(":");
|
||||
if (parts.length < 2) return null;
|
||||
const hh = parseInt(parts[0], 10);
|
||||
const mm = parseInt(parts[1], 10);
|
||||
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
|
||||
return (hh * 60) + mm;
|
||||
};
|
||||
|
||||
const minutesToHHMM = (mins) => {
|
||||
const safe = Math.max(0, mins);
|
||||
const hh = String(Math.floor(safe / 60)).padStart(2, "0");
|
||||
const mm = String(safe % 60).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
};
|
||||
|
||||
const roundUpTo30 = (mins) => Math.ceil(mins / 30) * 30;
|
||||
const roundDownTo30 = (mins) => Math.floor(mins / 30) * 30;
|
||||
|
||||
const mergeIntervals = (intervals) => {
|
||||
if (!Array.isArray(intervals) || intervals.length === 0) return [];
|
||||
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
||||
const merged = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const [curStart, curEnd] = sorted[i];
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (curStart <= last[1]) {
|
||||
last[1] = Math.max(last[1], curEnd);
|
||||
} else {
|
||||
merged.push([curStart, curEnd]);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const getMadridNowInfo = () => {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Madrid",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = fmt.formatToParts(new Date());
|
||||
const map = {};
|
||||
for (const p of parts) {
|
||||
if (p.type !== "literal") map[p.type] = p.value;
|
||||
}
|
||||
|
||||
return {
|
||||
todayISO: `${map.year}-${map.month}-${map.day}`,
|
||||
currentMinutes: (parseInt(map.hour, 10) * 60) + parseInt(map.minute, 10)
|
||||
};
|
||||
};
|
||||
|
||||
const addDaysISO = (isoDate, daysToAdd) => {
|
||||
const [y, m, d] = isoDate.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
dt.setUTCDate(dt.getUTCDate() + daysToAdd);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const isWeekendISO = (isoDate) => {
|
||||
const [y, m, d] = isoDate.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
const day = dt.getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
};
|
||||
|
||||
const formatDisplayDateES = (isoDate) => {
|
||||
const [y, m, d] = isoDate.split("-").map(Number);
|
||||
return new Date(y, m - 1, d, 12, 0, 0).toLocaleDateString("es-ES", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long"
|
||||
});
|
||||
};
|
||||
|
||||
const calcularHuecosDisponiblesExactos = async () => {
|
||||
if (!datosExpediente.worker_id) return [];
|
||||
|
||||
const agendaQ = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(raw_data->>'scheduled_date', ''),
|
||||
CASE
|
||||
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_date', '')
|
||||
ELSE NULL
|
||||
END
|
||||
) as effective_date,
|
||||
COALESCE(
|
||||
NULLIF(raw_data->>'scheduled_time', ''),
|
||||
CASE
|
||||
WHEN raw_data->>'appointment_status' = 'pending' THEN NULLIF(raw_data->>'requested_time', '')
|
||||
ELSE NULL
|
||||
END
|
||||
) as effective_time,
|
||||
COALESCE(NULLIF(raw_data->>'duration_minutes', ''), '60') as effective_duration
|
||||
FROM scraped_services
|
||||
WHERE owner_id = $1
|
||||
AND assigned_to = $2
|
||||
AND status != 'archived'
|
||||
AND id != $3
|
||||
AND (
|
||||
(raw_data->>'scheduled_date' IS NOT NULL AND raw_data->>'scheduled_date' >= CURRENT_DATE::text)
|
||||
OR
|
||||
(raw_data->>'appointment_status' = 'pending' AND raw_data->>'requested_date' >= CURRENT_DATE::text)
|
||||
)
|
||||
ORDER BY effective_date ASC, effective_time ASC
|
||||
`, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]);
|
||||
|
||||
const ocupacionesPorDia = {};
|
||||
|
||||
for (const row of agendaQ.rows) {
|
||||
const isoDate = row.effective_date;
|
||||
const startMin = parseTimeToMinutes(row.effective_time);
|
||||
const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1);
|
||||
|
||||
if (!isoDate || startMin === null) continue;
|
||||
|
||||
if (!ocupacionesPorDia[isoDate]) ocupacionesPorDia[isoDate] = [];
|
||||
ocupacionesPorDia[isoDate].push([startMin, startMin + duration]);
|
||||
}
|
||||
|
||||
const sesionesTrabajo = [
|
||||
[parseTimeToMinutes(horarios.m_start), parseTimeToMinutes(horarios.m_end)],
|
||||
[parseTimeToMinutes(horarios.a_start), parseTimeToMinutes(horarios.a_end)]
|
||||
].filter(([ini, fin]) => ini !== null && fin !== null && fin > ini);
|
||||
|
||||
const { todayISO, currentMinutes } = getMadridNowInfo();
|
||||
const diasDisponibles = [];
|
||||
|
||||
for (let offset = 0; offset < 21 && diasDisponibles.length < 10; offset++) {
|
||||
const isoDate = addDaysISO(todayISO, offset);
|
||||
if (isWeekendISO(isoDate)) continue;
|
||||
|
||||
const ocupadas = mergeIntervals(ocupacionesPorDia[isoDate] || []);
|
||||
const ventanas = [];
|
||||
|
||||
for (const [sesionStartBase, sesionEndBase] of sesionesTrabajo) {
|
||||
let sesionStart = sesionStartBase;
|
||||
const sesionEnd = sesionEndBase;
|
||||
|
||||
if (isoDate === todayISO) {
|
||||
sesionStart = Math.max(sesionStart, roundUpTo30(currentMinutes + 30));
|
||||
}
|
||||
|
||||
if ((sesionEnd - sesionStart) < 60) continue;
|
||||
|
||||
let cursor = sesionStart;
|
||||
|
||||
for (const [occStart, occEnd] of ocupadas) {
|
||||
if (occEnd <= sesionStart) continue;
|
||||
if (occStart >= sesionEnd) break;
|
||||
|
||||
const gapStart = roundUpTo30(cursor);
|
||||
const gapEnd = roundDownTo30(Math.min(occStart, sesionEnd));
|
||||
|
||||
if ((gapEnd - gapStart) >= 60) {
|
||||
ventanas.push({
|
||||
start: minutesToHHMM(gapStart),
|
||||
end: minutesToHHMM(gapEnd),
|
||||
startMin: gapStart,
|
||||
endMin: gapEnd
|
||||
});
|
||||
}
|
||||
|
||||
cursor = Math.max(cursor, occEnd);
|
||||
if (cursor >= sesionEnd) break;
|
||||
}
|
||||
|
||||
const tailStart = roundUpTo30(cursor);
|
||||
const tailEnd = roundDownTo30(sesionEnd);
|
||||
|
||||
if ((tailEnd - tailStart) >= 60) {
|
||||
ventanas.push({
|
||||
start: minutesToHHMM(tailStart),
|
||||
end: minutesToHHMM(tailEnd),
|
||||
startMin: tailStart,
|
||||
endMin: tailEnd
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ventanas.length > 0) {
|
||||
diasDisponibles.push({
|
||||
date: isoDate,
|
||||
displayDate: formatDisplayDateES(isoDate),
|
||||
windows: ventanas
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diasDisponibles;
|
||||
};
|
||||
|
||||
// =========================================================
|
||||
// HISTORIAL REAL DEL CHAT
|
||||
// =========================================================
|
||||
const historyQ = await pool.query(`
|
||||
SELECT sender_role, message
|
||||
FROM service_communications
|
||||
@@ -956,70 +1214,27 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
|
||||
content: row.message
|
||||
}));
|
||||
|
||||
// Si el historial solo tiene el mensaje actual del cliente, es primer contacto
|
||||
const esPrimerMensaje = historialRows.length <= 1;
|
||||
|
||||
let agendaOcupadaTexto = "✅ El técnico tiene la agenda libre en horario laboral.";
|
||||
// =========================================================
|
||||
// HUECOS EXACTOS CALCULADOS POR BACKEND
|
||||
// =========================================================
|
||||
let huecosExactos = [];
|
||||
let agendaDisponibleTexto = "❌ Ahora mismo no hay huecos exactos calculados por el sistema.";
|
||||
|
||||
if (datosExpediente.worker_id) {
|
||||
const agendaQ = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(NULLIF(raw_data->>'scheduled_date', ''), raw_data->>'requested_date') as date,
|
||||
COALESCE(NULLIF(raw_data->>'scheduled_time', ''), raw_data->>'requested_time') as time,
|
||||
raw_data->>'duration_minutes' as duration,
|
||||
COALESCE(raw_data->>'Población', raw_data->>'POBLACION-PROVINCIA') as pob,
|
||||
provider
|
||||
FROM scraped_services
|
||||
WHERE owner_id = $1
|
||||
AND assigned_to = $2
|
||||
AND status != 'archived'
|
||||
AND id != $3
|
||||
AND (
|
||||
(raw_data->>'scheduled_date' IS NOT NULL AND raw_data->>'scheduled_date' >= CURRENT_DATE::text)
|
||||
OR
|
||||
(raw_data->>'appointment_status' = 'pending' AND raw_data->>'requested_date' >= CURRENT_DATE::text)
|
||||
)
|
||||
ORDER BY date ASC, time ASC
|
||||
`, [ownerId, datosExpediente.worker_id, datosExpediente.dbId]);
|
||||
huecosExactos = await calcularHuecosDisponiblesExactos();
|
||||
|
||||
if (agendaQ.rowCount > 0) {
|
||||
const ocupaciones = {};
|
||||
if (huecosExactos.length > 0) {
|
||||
agendaDisponibleTexto = huecosExactos.map(dia => {
|
||||
const tramos = dia.windows
|
||||
.map(w => `entre las ${w.start} y las ${w.end} aprox`)
|
||||
.join(" | ");
|
||||
|
||||
agendaQ.rows.forEach(r => {
|
||||
if (r.date && r.time && r.time.includes(':')) {
|
||||
if (!ocupaciones[r.date]) ocupaciones[r.date] = [];
|
||||
|
||||
const [h, m] = r.time.split(':').map(Number);
|
||||
const dur = parseInt(r.duration || 60, 10);
|
||||
const endMin = (h * 60 + m) + dur;
|
||||
const endH = String(Math.floor(endMin / 60) % 24).padStart(2, '0');
|
||||
const endM = String(endMin % 60).padStart(2, '0');
|
||||
|
||||
const tipo = r.provider === 'SYSTEM_BLOCK' ? 'BLOQUEO/AUSENCIA' : 'CITA';
|
||||
const lugar = r.pob || 'Otra zona';
|
||||
|
||||
ocupaciones[r.date].push(`❌ OCUPADO de ${r.time} a ${endH}:${endM} (${tipo} en ${lugar})`);
|
||||
}
|
||||
});
|
||||
|
||||
const lineas = Object.keys(ocupaciones).sort().map(d => {
|
||||
const [y, m, day] = d.split('-').map(Number);
|
||||
const fechaHumana = new Date(y, m - 1, day, 12, 0, 0).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
|
||||
return `- Día ${fechaHumana} (${d}):\n * ${ocupaciones[d].join("\n * ")}`;
|
||||
});
|
||||
|
||||
if (lineas.length > 0) {
|
||||
agendaOcupadaTexto =
|
||||
"⚠️ ¡ATENCIÓN! LA SIGUIENTE LISTA SON LOS HORARIOS QUE YA ESTÁN OCUPADOS Y NO PUEDES OFRECER:\n" +
|
||||
lineas.join("\n") +
|
||||
"\n\n👉 INSTRUCCIÓN VITAL: Revisa bien la lista de arriba. Tienes que proponerle al cliente CUALQUIER OTRA HORA que esté totalmente libre y no pise esos tramos." +
|
||||
"\n🚨 REGLA LOGÍSTICA: Si la zona de la avería actual no es la misma que la de la cita anterior o posterior, DEBES dejar al menos 45-60 minutos libres para el viaje.";
|
||||
}
|
||||
return `- ${dia.displayDate} (${dia.date}): ${tramos}`;
|
||||
}).join("\n");
|
||||
} else {
|
||||
agendaDisponibleTexto = "❌ No hay huecos exactos libres en los próximos días laborables calculados por el sistema.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1083,47 +1298,63 @@ TU ÚNICO OBJETIVO: Informar al cliente que hemos recibido el aviso de su averí
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN.
|
||||
📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.
|
||||
TU OBJETIVO: Informar que esperamos confirmación del técnico para reparar su avería (${datosExpediente.averia}).
|
||||
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo.`;
|
||||
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`;
|
||||
} else if (tieneCitaConfirmada) {
|
||||
directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}).
|
||||
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo.`;
|
||||
⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo usando SOLO los huecos exactos calculados por el sistema.`;
|
||||
} else {
|
||||
directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.
|
||||
TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}). NUNCA ofrezcas horas ocupadas. Fines de semana solo URGENCIAS.
|
||||
TU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}) usando SOLO los huecos exactos calculados por el sistema.
|
||||
⚠️ MUY IMPORTANTE: Cuando el cliente elija un hueco, NO le digas que la cita está confirmada. Dile que le pasas la nota al técnico para que él lo valide.`;
|
||||
}
|
||||
|
||||
const promptSistema = `
|
||||
Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y con buen tono por WhatsApp.
|
||||
Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma natural, clara, cercana y profesional por WhatsApp.
|
||||
|
||||
--- 📋 CONTEXTO BÁSICO ---
|
||||
- Hoy es: ${fechaHoyTexto}.
|
||||
- Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}.
|
||||
- ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni pises el tramo de comer. Fines de semana prohibidos salvo urgencias.
|
||||
- ⛔ REGLA DE ORO DEL HORARIO: NUNCA propongas horas fuera de ese horario ni cruces el tramo de comer.
|
||||
- Fines de semana solo si es una urgencia.
|
||||
- Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}.
|
||||
|
||||
--- 📅 REVISIÓN DE AGENDA (EVITAR SOLAPES) ---
|
||||
${agendaOcupadaTexto}
|
||||
--- ✅ HUECOS EXACTOS CALCULADOS POR EL SISTEMA ---
|
||||
${agendaDisponibleTexto}
|
||||
|
||||
--- 🚨 NORMA ABSOLUTA DE AGENDA ---
|
||||
Los huecos anteriores son la ÚNICA verdad.
|
||||
NO puedes inventar horas.
|
||||
NO puedes deducir huecos por tu cuenta.
|
||||
NO puedes reinterpretar horarios ocupados.
|
||||
SOLO puedes ofrecer horas que estén dentro de esos huecos exactos calculados por el sistema.
|
||||
Si el cliente pide una hora concreta y esa hora cae dentro de uno de esos huecos, puedes aceptarla tentativamente.
|
||||
Si cae fuera, debes decir que no la ves libre y ofrecer una alternativa de la lista exacta.
|
||||
|
||||
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
|
||||
${directivaEstricta}
|
||||
|
||||
--- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) ---
|
||||
Si (y solo si) has propuesto un hueco y el cliente ACEPTA FIRMEMENTE, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal:
|
||||
Si (y solo si) el cliente ACEPTA claramente un hueco concreto, DEBES añadir AL FINAL ABSOLUTO de tu respuesta este texto literal:
|
||||
[PROPUESTA:YYYY-MM-DD HH:mm]
|
||||
|
||||
Ejemplo:
|
||||
"Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 10:00 y las 11:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 10:00]"
|
||||
"Perfecto, le paso la nota al técnico para que te confirme el miércoles entre las 12:00 y las 13:00 aprox. ¡Te decimos algo pronto! [PROPUESTA:2026-03-25 12:00]"
|
||||
|
||||
⛔ PROHIBICIÓN: NUNCA le digas "te agendo" ni "cita confirmada". El cliente debe saber que dependemos del técnico. NUNCA menciones las palabras "código" o "etiqueta".
|
||||
⛔ PROHIBICIONES:
|
||||
- NUNCA digas "te agendo".
|
||||
- NUNCA digas "cita confirmada".
|
||||
- NUNCA menciones las palabras "código" o "etiqueta".
|
||||
- NUNCA ofrezcas huecos distintos a los calculados.
|
||||
- Si el cliente pregunta por "la semana que viene", responde usando solo los huecos de esa semana.
|
||||
|
||||
--- ⚙️ REGLAS DE COMUNICACIÓN ---
|
||||
1. MÁXIMO 2 FRASES. Mensajes cortos y directos.
|
||||
2. NUNCA uses fechas frías si puedes decir "el martes". NUNCA des una hora exacta si puedes decir "entre las 10:00 y las 11:00 aprox".
|
||||
3. NO TE PRESENTES si ya habéis intercambiado mensajes.
|
||||
4. ⛔ MULETILLAS PROHIBIDAS: NUNCA digas "¿En qué más te puedo ayudar?".
|
||||
${esPrimerMensaje ? `5. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''}
|
||||
${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtra}` : ''}
|
||||
1. MÁXIMO 2 FRASES.
|
||||
2. Mensajes cortos, claros y directos.
|
||||
3. NO TE PRESENTES si ya habéis hablado antes.
|
||||
4. NO uses la muletilla "¿En qué más te puedo ayudar?".
|
||||
5. Si el cliente pide disponibilidad general, resume las opciones reales sin inventar nada.
|
||||
${esPrimerMensaje ? `6. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''}
|
||||
${instruccionesExtra ? `7. Instrucción extra de la empresa: ${instruccionesExtra}` : ''}
|
||||
`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
@@ -1142,7 +1373,6 @@ ${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB)
|
||||
// ==========================================
|
||||
@@ -4122,10 +4352,14 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
// Ignorar grupos, estados y cosas raras
|
||||
if (!/@s\.whatsapp\.net$/i.test(remoteJid)) {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
// Respondemos rápido a Evolution
|
||||
res.sendStatus(200);
|
||||
|
||||
// Tu arquitectura actual depende de cliente_ID
|
||||
if (!/^cliente_\d+$/.test(instanceName)) {
|
||||
console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`);
|
||||
return;
|
||||
@@ -4155,7 +4389,10 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
COALESCE(s.raw_data->>'requested_time', '') as cita_pendiente_hora,
|
||||
COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania,
|
||||
COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION', '') as averia,
|
||||
COALESCE((s.raw_data->>'ia_paused')::boolean, false) as ia_paused
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true
|
||||
ELSE false
|
||||
END as ia_paused
|
||||
FROM scraped_services s
|
||||
LEFT JOIN users u ON s.assigned_to = u.id
|
||||
LEFT JOIN service_statuses st ON (s.raw_data->>'status_operativo')::text = st.id::text
|
||||
@@ -4272,7 +4509,6 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
console.error("Error guardando mensaje del cliente:", err);
|
||||
}
|
||||
|
||||
// Candado para que no responda dos veces a la vez
|
||||
if (candadosIA.has(service.id)) return;
|
||||
candadosIA.add(service.id);
|
||||
|
||||
@@ -4283,7 +4519,7 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🛡️ Escudo humano: si alguien de oficina/operario habló hace menos de 120 min, la IA calla
|
||||
// 🛡️ Escudo humano: si alguien habló hace menos de 120 min, la IA se calla
|
||||
const checkHumanQ = await pool.query(`
|
||||
SELECT sender_role, created_at
|
||||
FROM service_communications
|
||||
@@ -4303,7 +4539,7 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 🧠 Llamada a la IA
|
||||
// 🧠 LLAMADA A LA IA
|
||||
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
|
||||
dbId: service.id,
|
||||
ref: service.service_ref,
|
||||
@@ -4324,7 +4560,10 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
if (!respuestaIA) return;
|
||||
|
||||
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
|
||||
let textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/gi, "").trim();
|
||||
let textoLimpio = respuestaIA
|
||||
.replace(/\[PROPUESTA:.*?\]/gi, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
if (matchPropuesta && service.assigned_to) {
|
||||
const fechaSugerida = matchPropuesta[1];
|
||||
@@ -4341,7 +4580,7 @@ app.post("/webhook/evolution", async (req, res) => {
|
||||
|
||||
if (disponibilidad.choca) {
|
||||
console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`);
|
||||
textoLimpio = "Uy, justo ese hueco acaba de quedar ocupado en el sistema. Dime otra hora y lo reviso.";
|
||||
textoLimpio = "Uy, justo ese hueco ya no lo veo libre en el sistema. Dime otra hora y lo reviso.";
|
||||
} else {
|
||||
await pool.query(`
|
||||
UPDATE scraped_services
|
||||
|
||||
Reference in New Issue
Block a user