diff --git a/server.js b/server.js index 64847cd..fa4bd5e 100644 --- a/server.js +++ b/server.js @@ -937,64 +937,57 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { if (citaTime < hoyTime) citaYaPaso = true; } - // 🛑 AÑADIDO: DETECTOR DE ESTADO FINALIZADO Y COMPAÑÍA + // 🛑 DETECTORES DE ESTADO const esEstadoFinal = datosExpediente.estado && (datosExpediente.estado.toLowerCase().includes('finalizado') || datosExpediente.estado.toLowerCase().includes('terminado') || datosExpediente.estado.toLowerCase().includes('anulado')); const nombreCia = datosExpediente.compania || "su Aseguradora"; const esSeguro = !nombreCia.toLowerCase().includes('particular'); + const noTieneTecnico = !datosExpediente.worker_id; - let directivaEstricta = ""; + let directivaEstricta = ""; if (esEstadoFinal) { - if (esSeguro) { - directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO CERRADO. Informa al cliente que el informe está enviado a ${nombreCia} y esperamos respuesta. NO AGENDES NADA.`; - } else { - directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO CERRADO. Despídete o da soporte post-servicio. NO AGENDES NADA.`; - } + directivaEstricta = `🛑 ESTADO ACTUAL: SERVICIO CERRADO. Informa al cliente que el servicio por su avería (${datosExpediente.averia}) está finalizado. NO AGENDES NADA.`; + } else if (noTieneTecnico) { + directivaEstricta = `🛑 ESTADO ACTUAL: SIN TÉCNICO ASIGNADO.\nTU ÚNICO OBJETIVO: Informar al cliente que hemos recibido el aviso de su avería (${datosExpediente.averia}) y que estamos coordinando para asignarle un técnico en su zona.\n⛔ PROHIBICIÓN ABSOLUTA: NO ofrezcas citas, NO des horas, NO agendes nada hasta que se le asigne un técnico.`; } else if (citaYaPaso) { - if (esSeguro) { - directivaEstricta = `🛑 ESTADO ACTUAL: LA CITA YA PASÓ (${datosExpediente.cita}). Informa que estamos tramitando el informe con ${nombreCia}. NO AGENDES NADA.`; - } else { - directivaEstricta = `🛑 ESTADO ACTUAL: LA CITA YA PASÓ (${datosExpediente.cita}). Pregunta si el problema quedó resuelto. NO AGENDES NADA.`; - } + directivaEstricta = `🛑 ESTADO ACTUAL: LA CITA YA PASÓ (${datosExpediente.cita}). Informa que estamos tramitando su avería (${datosExpediente.averia}). NO AGENDES NADA.`; } else if (esUrgencia) { - directivaEstricta = `🛑 ESTADO ACTUAL: URGENCIA. Tranquiliza al cliente y dile que el técnico está avisado. NO PROPONGAS HORAS.`; + directivaEstricta = `🛑 ESTADO ACTUAL: URGENCIA. Tranquiliza al cliente sobre su avería (${datosExpediente.averia}) y dile que el técnico está avisado. NO PROPONGAS HORAS.`; } else if (hayCitaPendiente) { - directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN.\n📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.\nTU ÚNICO OBJETIVO: Informar que esperamos confirmación.\n⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco libre nuevo y si acepta, lanza la etiqueta oculta: [PROPUESTA:YYYY-MM-DD HH:mm]`; + directivaEstricta = `🛑 ESTADO ACTUAL: CITA PENDIENTE DE APROBACIÓN.\n📅 Propuesta actual: El día ${datosExpediente.cita_pendiente_fecha} ${tramoPendiente}.\nTU OBJETIVO: Informar que esperamos confirmación de la oficina para reparar su avería (${datosExpediente.averia}).\n⚠️ EXCEPCIÓN: Si el cliente pide CAMBIAR o CANCELAR, ofrécele un hueco nuevo.`; } else if (tieneCitaConfirmada) { - directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita.\n⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco libre nuevo y si acepta, lanza la etiqueta oculta: [PROPUESTA:YYYY-MM-DD HH:mm]`; + directivaEstricta = `🛑 ESTADO ACTUAL: CITA CONFIRMADA para el ${datosExpediente.cita} ${tramoConfirmado}. Recuerda la cita para su avería (${datosExpediente.averia}).\n⚠️ EXCEPCIÓN: Si el cliente pide CAMBIARLA o CANCELARLA, ofrécele un hueco nuevo.`; } else { - directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.\nTU OBJETIVO: Acordar fecha y hora. NUNCA ofrezcas horas ocupadas. Fines de semana solo URGENCIAS.\n⚠️ MUY IMPORTANTE: Si el cliente ACEPTA un hueco, aclárale que le pasas la propuesta al técnico para confirmación final. Añade AL FINAL la etiqueta oculta: [PROPUESTA:YYYY-MM-DD HH:mm]`; + directivaEstricta = `🟢 ESTADO ACTUAL: PENDIENTE DE AGENDAR CITA.\nTU OBJETIVO: Acordar fecha y hora para reparar su avería (${datosExpediente.averia}). NUNCA ofrezcas horas ocupadas. Fines de semana solo URGENCIAS.`; } const promptSistema = ` Eres el coordinador humano de "${empresaNombre}". Hablas de tú, de forma muy natural, empática y al con un buen sentido de humor por WhatsApp. - --- CONTEXTO BÁSICO --- + --- 📋 CONTEXTO BÁSICO --- - Hoy es: ${fechaHoyTexto}. (Año 2026). - Horario de la empresa: L-V de ${horarios.m_start} a ${horarios.m_end} y de ${horarios.a_start} a ${horarios.a_end}. Fines de semana solo URGENCIAS. - - Problema o Avería reportada por el cliente: ${datosExpediente.averia || 'Avería general (no especificada)'}. - Localidad del cliente actual: ${datosExpediente.poblacion || 'Localidad no especificada'}. - --- AGENDA DEL TÉCNICO ASIGNADO --- + --- 📅 AGENDA DEL TÉCNICO ASIGNADO --- ${agendaOcupadaTexto} --- 🎯 DIRECTIVA ESTRICTA PARA ESTE MENSAJE --- ${directivaEstricta} - --- 🗓️ REGLAS ESTRICTAS DE FECHA Y HORA (FORMATO HUMANO) --- - 1. DÍAS DE LA SEMANA: NUNCA uses fechas frías como "2026-03-10". Usa SIEMPRE el día de la semana y el mes (Ej: "el martes de la semana que viene", "el jueves día 12"). - 2. TRAMOS HORARIOS: NUNCA le des una hora exacta al cliente. Usa SIEMPRE un margen de 1 hora. (Ej: "entre las 10:00 y las 11:00 aprox"). - 3. (Si estás agendando): Aunque hables en tramos, tu código interno de cierre DEBE SER la hora exacta de inicio en formato reloj: [PROPUESTA:YYYY-MM-DD HH:mm] + --- ⚡ 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: + [PROPUESTA:YYYY-MM-DD HH:mm] + Ejemplo de respuesta tuya: "Perfecto, te agendo para el miércoles entre las 10:00 y las 11:00. ¡Nos vemos! [PROPUESTA:2026-03-25 10:00]" + ⛔ PROHIBICIÓN: NUNCA menciones al cliente las palabras "código", "confirmación" ni "propuesta". Solo pega los corchetes al final de tu mensaje y ya está. - --- 📝 INSTRUCCIONES PERSONALIZADAS DE LA EMPRESA --- - ${instruccionesExtra ? instruccionesExtra : 'No hay reglas extra.'} - - --- REGLAS DE ORO DE COMUNICACIÓN --- - 0. LA BASE DE DATOS MANDA: Los datos del "ESTADO ACTUAL" son la única verdad. Si contradicen el historial, la oficina ha modificado la cita. - 1. Máximo 2 frases. Mensajes cortos y directos. - 2. ⛔ MULETILLAS PROHIBIDAS: NUNCA termines tus frases diciendo "Si necesitas algo más, aquí estoy", "¿En qué más te puedo ayudar?" o similares. Suena a contestador automático. Da la información y pon un punto y final. - 3. NO TE PRESENTES si ya habéis intercambiado mensajes antes. - ${esPrimerMensaje ? '4. Primer mensaje: preséntate brevemente diciendo de dónde eres y el aviso (#' + datosExpediente.ref + ').' : ''} + --- ⚙️ REGLAS DE COMUNICACIÓN --- + 1. MÁXIMO 2 FRASES. Mensajes cortos y directos. + 2. NUNCA uses fechas frías (Usa "el martes"). NUNCA des una hora exacta (Usa "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?". Da la información y pon un punto. + ${esPrimerMensaje ? '5. Primer mensaje: preséntate y menciona el aviso (#' + datosExpediente.ref + ').' : ''} + ${instruccionesExtra ? '6. Instrucción extra de la empresa: ' + instruccionesExtra : ''} `; const completion = await openai.chat.completions.create({ @@ -3935,7 +3928,7 @@ app.post("/webhook/evolution", async (req, res) => { const ownerId = instanceName.split("_")[1]; const cleanPhone = telefonoCliente.slice(-9); - // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE (Ignorando finalizados/anulados) + // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE const svcQ = await pool.query(` SELECT s.id, s.service_ref, s.assigned_to, u.full_name as worker_name, s.is_urgent, st.name as status_name, @@ -3945,14 +3938,14 @@ app.post("/webhook/evolution", async (req, res) => { s.raw_data->>'appointment_status' as appointment_status, s.raw_data->>'requested_date' as cita_pendiente_fecha, s.raw_data->>'requested_time' as cita_pendiente_hora, - s.raw_data->>'Compañía' as compania + s.raw_data->>'Compañía' as compania, + COALESCE(s.raw_data->>'Descripción', s.raw_data->>'DESCRIPCION') as averia 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 WHERE s.owner_id = $1 AND s.status != 'archived' AND s.raw_data::text ILIKE $2 - -- 👇 MAGIA: Excluimos los estados muertos para que no coja un finalizado si tiene varios partes AND (st.name IS NULL OR (st.name NOT ILIKE '%finalizado%' AND st.name NOT ILIKE '%anulado%' AND st.name NOT ILIKE '%desasignado%')) ORDER BY s.created_at DESC LIMIT 1 `, [ownerId, `%${cleanPhone}%`]); @@ -3960,19 +3953,13 @@ app.post("/webhook/evolution", async (req, res) => { if (svcQ.rowCount > 0) { const service = svcQ.rows[0]; - // 🚨 CAMBIO 2: EL NUEVO ESCUDO HUMANO 🚨 - // Si detecta que el mensaje lo has mandado TÚ desde tu móvil de empresa if (data.data.key.fromMe) { - // Lo guarda en la base de datos para que quede constancia y la IA sepa que estás al mando await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message) VALUES ($1, $2, $3, $4, $5)`, [service.id, ownerId, "Técnico (WhatsApp)", "operario", mensajeTexto]); - return; // Cortamos la ejecución. ¡ChatGPT no dirá ni mu! + return; } - // 🛑 SEMÁFORO ANTI-METRALLETA - if (candadosIA.has(service.id)) { - return; - } + if (candadosIA.has(service.id)) return; candadosIA.add(service.id); try { @@ -3985,11 +3972,11 @@ app.post("/webhook/evolution", async (req, res) => { if (checkHumanQ.rowCount > 0) { const lastMsg = checkHumanQ.rows[0]; const diffMinutos = (new Date() - new Date(lastMsg.created_at)) / (1000 * 60); - // Como tu mensaje se guardó como 'operario', aquí saltará esta regla y detendrá a la IA durante 120 min + // PUESTO A 0 PARA PRUEBAS: CÁMBIALO A 120 CUANDO TERMINES if (['admin', 'superadmin', 'operario'].includes(lastMsg.sender_role) && diffMinutos < 0) return; } - // 🧠 LLAMADA A LA IA (Con la hora inyectada) + // 🧠 LLAMADA A LA IA const respuestaIA = await procesarConIA(ownerId, mensajeTexto, { dbId: service.id, ref: service.service_ref, @@ -3997,37 +3984,47 @@ app.post("/webhook/evolution", async (req, res) => { operario: service.worker_name, worker_id: service.assigned_to, cita: service.cita, - hora_cita: service.hora_cita, // 👈 AHORA SÍ PASA LA HORA EXACTA + hora_cita: service.hora_cita, poblacion: service.poblacion || "", is_urgent: service.is_urgent, appointment_status: service.appointment_status, cita_pendiente_fecha: service.cita_pendiente_fecha, cita_pendiente_hora: service.cita_pendiente_hora, - compania: service.compania // 👈 NUEVO: PASAMOS LA COMPAÑÍA + compania: service.compania, + averia: service.averia }); if (respuestaIA) { + // 🛡️ REGEX BLINDADO: Pilla la etiqueta aunque la IA meta espacios raros const matchPropuesta = respuestaIA.match(/\[PROPUESTA:\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]/i); + if (matchPropuesta) { const fechaSugerida = matchPropuesta[1]; const horaSugerida = matchPropuesta[2]; - await pool.query(` - UPDATE scraped_services - SET raw_data = raw_data || jsonb_build_object( - 'requested_date', $1::text, - 'requested_time', $2::text, - 'appointment_status', 'pending' - ) WHERE id = $3 - `, [fechaSugerida, horaSugerida, service.id]); + + // 🚀 GUARDADO DIRECTO A CITA CONFIRMADA (Va al calendario del Operario sin preguntar) + const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [ownerId]); + const idCitado = statusQ.rowCount > 0 ? String(statusQ.rows[0].id) : null; + + const rawQ = await pool.query("SELECT raw_data FROM scraped_services WHERE id=$1", [service.id]); + let rawActual = rawQ.rows[0].raw_data || {}; + + rawActual.scheduled_date = fechaSugerida; + rawActual.scheduled_time = horaSugerida; + rawActual.appointment_status = 'approved'; + if (idCitado) rawActual.status_operativo = idCitado; + + await pool.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(rawActual), service.id]); } - const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/, "").trim(); + // 🧹 BORRAMOS EL TEXTO DEL CÓDIGO PARA QUE EL CLIENTE NO LO VEA NUNCA + const textoLimpio = respuestaIA.replace(/\[PROPUESTA:.*?\]/gi, "").replace(/código:/gi, "").trim(); + await sendWhatsAppAuto(telefonoCliente, textoLimpio, instanceName, true); await pool.query(`INSERT INTO service_communications (scraped_id, owner_id, sender_name, sender_role, message) VALUES ($1, $2, $3, $4, $5)`, [service.id, ownerId, "Asistente IA", "ia", textoLimpio]); } } finally { - // 🟢 ABRIMOS EL CANDADO SIEMPRE AL TERMINAR (Aunque haya error) candadosIA.delete(service.id); } }