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
|
// 🛡️ ESCUDO DE TITANIO: ANTI-SOLAPAMIENTOS BBDD
|
||||||
// ==========================================
|
// ==========================================
|
||||||
async function comprobarDisponibilidad(ownerId, workerId, date, time, durationMin, excludeId = null) {
|
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);
|
const parseTimeToMinutes = (value) => {
|
||||||
let newStart = newH * 60 + newM;
|
if (!value) return null;
|
||||||
let newEnd = newStart + (parseInt(durationMin) || 60);
|
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 = `
|
let query = `
|
||||||
SELECT id, service_ref, raw_data->>'scheduled_time' as time, raw_data->>'duration_minutes' as dur,
|
SELECT
|
||||||
raw_data->>'requested_time' as req_time, raw_data->>'appointment_status' as app_status
|
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
|
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 (
|
AND (
|
||||||
raw_data->>'scheduled_date' = $3 OR
|
raw_data->>'scheduled_date' = $3
|
||||||
(raw_data->>'requested_date' = $3 AND raw_data->>'appointment_status' = 'pending')
|
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);
|
const q = await pool.query(query, params);
|
||||||
|
|
||||||
for (const s of q.rows) {
|
for (const row of q.rows) {
|
||||||
let sTime = s.time || (s.app_status === 'pending' ? s.req_time : null);
|
const start = parseTimeToMinutes(row.effective_time);
|
||||||
if (sTime && sTime.includes(':')) {
|
if (start === null) continue;
|
||||||
let [sH, sM] = sTime.split(':').map(Number);
|
|
||||||
let sStart = sH * 60 + sM;
|
const duration = Math.max(parseInt(row.effective_duration, 10) || 60, 1);
|
||||||
let sEnd = sStart + parseInt(s.dur || 60);
|
const end = start + duration;
|
||||||
if (newStart < sEnd && newEnd > sStart) return { choca: true, ref: s.service_ref, time: sTime };
|
|
||||||
|
if (requestedStart < end && requestedEnd > start) {
|
||||||
|
return {
|
||||||
|
choca: true,
|
||||||
|
ref: row.service_ref || `ID-${row.id}`,
|
||||||
|
time: row.effective_time
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { choca: false };
|
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"
|
day: "numeric"
|
||||||
}).format(new Date());
|
}).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(`
|
const historyQ = await pool.query(`
|
||||||
SELECT sender_role, message
|
SELECT sender_role, message
|
||||||
FROM service_communications
|
FROM service_communications
|
||||||
@@ -956,70 +1214,27 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) {
|
|||||||
content: row.message
|
content: row.message
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Si el historial solo tiene el mensaje actual del cliente, es primer contacto
|
|
||||||
const esPrimerMensaje = historialRows.length <= 1;
|
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) {
|
if (datosExpediente.worker_id) {
|
||||||
const agendaQ = await pool.query(`
|
huecosExactos = await calcularHuecosDisponiblesExactos();
|
||||||
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]);
|
|
||||||
|
|
||||||
if (agendaQ.rowCount > 0) {
|
if (huecosExactos.length > 0) {
|
||||||
const ocupaciones = {};
|
agendaDisponibleTexto = huecosExactos.map(dia => {
|
||||||
|
const tramos = dia.windows
|
||||||
|
.map(w => `entre las ${w.start} y las ${w.end} aprox`)
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
agendaQ.rows.forEach(r => {
|
return `- ${dia.displayDate} (${dia.date}): ${tramos}`;
|
||||||
if (r.date && r.time && r.time.includes(':')) {
|
}).join("\n");
|
||||||
if (!ocupaciones[r.date]) ocupaciones[r.date] = [];
|
} else {
|
||||||
|
agendaDisponibleTexto = "❌ No hay huecos exactos libres en los próximos días laborables calculados por el sistema.";
|
||||||
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.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN.
|
||||||
📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.
|
📅 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}).
|
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) {
|
} else if (tieneCitaConfirmada) {
|
||||||
directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}).
|
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 {
|
} else {
|
||||||
directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.
|
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.`;
|
⚠️ 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 = `
|
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 ---
|
--- 📋 CONTEXTO BÁSICO ---
|
||||||
- Hoy es: ${fechaHoyTexto}.
|
- 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}.
|
- 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'}.
|
- Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}.
|
||||||
|
|
||||||
--- 📅 REVISIÓN DE AGENDA (EVITAR SOLAPES) ---
|
--- ✅ HUECOS EXACTOS CALCULADOS POR EL SISTEMA ---
|
||||||
${agendaOcupadaTexto}
|
${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 ---
|
--- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE ---
|
||||||
${directivaEstricta}
|
${directivaEstricta}
|
||||||
|
|
||||||
--- ⚡ REGLA CRÍTICA DE AGENDA (COMANDO SECRETO) ---
|
--- ⚡ 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]
|
[PROPUESTA:YYYY-MM-DD HH:mm]
|
||||||
|
|
||||||
Ejemplo:
|
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 ---
|
--- ⚙️ REGLAS DE COMUNICACIÓN ---
|
||||||
1. MÁXIMO 2 FRASES. Mensajes cortos y directos.
|
1. MÁXIMO 2 FRASES.
|
||||||
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".
|
2. Mensajes cortos, claros y directos.
|
||||||
3. NO TE PRESENTES si ya habéis intercambiado mensajes.
|
3. NO TE PRESENTES si ya habéis hablado antes.
|
||||||
4. ⛔ MULETILLAS PROHIBIDAS: NUNCA digas "¿En qué más te puedo ayudar?".
|
4. NO uses la muletilla "¿En qué más te puedo ayudar?".
|
||||||
${esPrimerMensaje ? `5. Primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''}
|
5. Si el cliente pide disponibilidad general, resume las opciones reales sin inventar nada.
|
||||||
${instruccionesExtra ? `6. Instrucción extra de la empresa: ${instruccionesExtra}` : ''}
|
${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({
|
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)
|
// 📱 OTP PARA PORTAL DEL CLIENTE (ACCESO WEB)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -4122,10 +4352,14 @@ app.post("/webhook/evolution", async (req, res) => {
|
|||||||
return res.sendStatus(200);
|
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
|
// Respondemos rápido a Evolution
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
|
|
||||||
// Tu arquitectura actual depende de cliente_ID
|
|
||||||
if (!/^cliente_\d+$/.test(instanceName)) {
|
if (!/^cliente_\d+$/.test(instanceName)) {
|
||||||
console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`);
|
console.log(`⚠️ [WEBHOOK IA] Instancia ignorada: ${instanceName}`);
|
||||||
return;
|
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->>'requested_time', '') as cita_pendiente_hora,
|
||||||
COALESCE(s.raw_data->>'Compañía', s.raw_data->>'COMPAÑIA', '') as compania,
|
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->>'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
|
FROM scraped_services s
|
||||||
LEFT JOIN users u ON s.assigned_to = u.id
|
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
|
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);
|
console.error("Error guardando mensaje del cliente:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candado para que no responda dos veces a la vez
|
|
||||||
if (candadosIA.has(service.id)) return;
|
if (candadosIA.has(service.id)) return;
|
||||||
candadosIA.add(service.id);
|
candadosIA.add(service.id);
|
||||||
|
|
||||||
@@ -4283,7 +4519,7 @@ app.post("/webhook/evolution", async (req, res) => {
|
|||||||
return;
|
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(`
|
const checkHumanQ = await pool.query(`
|
||||||
SELECT sender_role, created_at
|
SELECT sender_role, created_at
|
||||||
FROM service_communications
|
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, {
|
const respuestaIA = await procesarConIA(ownerId, mensajeTexto, {
|
||||||
dbId: service.id,
|
dbId: service.id,
|
||||||
ref: service.service_ref,
|
ref: service.service_ref,
|
||||||
@@ -4324,7 +4560,10 @@ app.post("/webhook/evolution", async (req, res) => {
|
|||||||
if (!respuestaIA) return;
|
if (!respuestaIA) return;
|
||||||
|
|
||||||
const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i);
|
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) {
|
if (matchPropuesta && service.assigned_to) {
|
||||||
const fechaSugerida = matchPropuesta[1];
|
const fechaSugerida = matchPropuesta[1];
|
||||||
@@ -4341,7 +4580,7 @@ app.post("/webhook/evolution", async (req, res) => {
|
|||||||
|
|
||||||
if (disponibilidad.choca) {
|
if (disponibilidad.choca) {
|
||||||
console.log(`⛔ [DOBLE-BOOKING EVITADO] ${service.service_ref} chocaba con ${disponibilidad.ref}`);
|
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 {
|
} else {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
UPDATE scraped_services
|
UPDATE scraped_services
|
||||||
|
|||||||
Reference in New Issue
Block a user