diff --git a/server.js b/server.js index 69cd1bf..194106e 100644 --- a/server.js +++ b/server.js @@ -2074,6 +2074,105 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { } } + // ========================================================= + // 💰 MODO PRESUPUESTO: expedientes abiertos automáticamente porque el + // cliente escribió y tenía un presupuesto (budgets) nuestro sin convertir. + // Mientras no esté aceptado, la IA solo puede resolver dudas sobre su + // contenido — nunca ofrecer cita. En cuanto el presupuesto pasa a + // aceptado/pagado (por el cliente en el portal o por ti a mano), detectamos + // gremio por palabras clave y asignamos técnico automáticamente (igual que + // en el alta por WhatsApp), y el resto de la conversación sigue el flujo + // normal de agenda de siempre. + // ========================================================= + let datosPresupuestoTexto = ""; + if (datosExpediente.budget_id) { + try { + const budgetQ = await pool.query("SELECT * FROM budgets WHERE id = $1 AND owner_id = $2", [datosExpediente.budget_id, ownerId]); + if (budgetQ.rowCount > 0) { + const budget = budgetQ.rows[0]; + const itemsTexto = Array.isArray(budget.items) + ? budget.items.map(i => `- ${i.qty || 1}x ${i.concept || ''}${i.price ? ` (${i.price}€/ud)` : ''}`).join("\n") + : ""; + + datosPresupuestoTexto = ` +--- 💰 DATOS DEL PRESUPUESTO PRE-${budget.id} (fijos, no editables desde el panel) --- +Conceptos: +${itemsTexto || "(sin detalle de conceptos)"} +Subtotal: ${budget.subtotal}€ · Impuestos: ${budget.tax}€ · TOTAL: ${budget.total}€ +Estado actual del presupuesto: ${String(budget.status || '').toUpperCase()} +Usa estos datos para responder con naturalidad las dudas del cliente sobre el presupuesto (qué incluye, precios, etc.).`; + + const yaEstaAceptado = budget.status === 'accepted' || budget.status === 'paid'; + + if (datosExpediente.budget_mode && yaEstaAceptado) { + // 🔎 Detección de gremio por palabras clave (misma lógica que el resto + // del sistema), buscando en los conceptos del propio presupuesto. + const textoParaGremio = `${itemsTexto} ${datosExpediente.averia || ''}`.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); + let guildAsignado = null; + const allGuildsQ = await pool.query("SELECT id, name, ia_keywords FROM guilds WHERE owner_id = $1", [ownerId]); + for (const g of allGuildsQ.rows) { + const keywords = Array.isArray(g.ia_keywords) ? g.ia_keywords : []; + if (keywords.some(kw => textoParaGremio.includes(String(kw).toLowerCase()))) { + guildAsignado = g.id; + break; + } + } + + // 🟡 TEMPORAL (mismo criterio que el alta por WhatsApp): si no hay + // técnico en el gremio detectado, caemos siempre en Marco Rincón. + const TECNICO_FIJO_TEMPORAL_PHONE = "+34687845940"; // Marco Rincón + let workerAsignado = null; + let workerQ = await pool.query( + "SELECT id FROM users WHERE owner_id = $1 AND status = 'active' AND phone = $2 LIMIT 1", + [ownerId, TECNICO_FIJO_TEMPORAL_PHONE] + ); + if (guildAsignado) { + const workerGremioQ = await pool.query(` + SELECT u.id FROM users u + JOIN user_guilds ug ON u.id = ug.user_id + WHERE u.owner_id = $1 AND u.status = 'active' AND ug.guild_id = $2 + ORDER BY RANDOM() LIMIT 1 + `, [ownerId, guildAsignado]); + if (workerGremioQ.rowCount > 0) workerQ = workerGremioQ; + } + if (workerQ.rowCount === 0) { + workerQ = await pool.query("SELECT id FROM users WHERE owner_id = $1 AND status = 'active' ORDER BY RANDOM() LIMIT 1", [ownerId]); + } + if (workerQ.rowCount > 0) workerAsignado = workerQ.rows[0].id; + + const patch = { "budget_mode": false }; + if (guildAsignado) patch["guild_id"] = guildAsignado; + if (workerAsignado) { + const statusAsignadoQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name = 'Asignado' LIMIT 1", [ownerId]); + if (statusAsignadoQ.rowCount > 0) patch["status_operativo"] = String(statusAsignadoQ.rows[0].id); + } + + if (workerAsignado) { + await pool.query( + `UPDATE scraped_services SET raw_data = COALESCE(raw_data,'{}'::jsonb) || $1::jsonb, assigned_to = $2 WHERE id = $3`, + [JSON.stringify(patch), workerAsignado, datosExpediente.dbId] + ); + } else { + await pool.query( + `UPDATE scraped_services SET raw_data = COALESCE(raw_data,'{}'::jsonb) || $1::jsonb WHERE id = $2`, + [JSON.stringify(patch), datosExpediente.dbId] + ); + } + + if (budget.status !== 'converted') { + await pool.query("UPDATE budgets SET status = 'converted' WHERE id = $1", [budget.id]); + } + + datosExpediente.budget_mode = false; + if (guildAsignado) datosExpediente.guild_id = guildAsignado; + if (workerAsignado) datosExpediente.worker_id = workerAsignado; + } + } + } catch (eBudgetMode) { + console.error("❌ Error en modo presupuesto:", eBudgetMode.message); + } + } + // ========================================================= // HUECOS EXACTOS CALCULADOS POR BACKEND // ========================================================= @@ -2149,7 +2248,12 @@ async function procesarConIA(ownerId, mensajeCliente, datosExpediente) { let directivaEstricta = ""; - if (datosExpediente.intake_mode) { + if (datosExpediente.budget_mode) { + directivaEstricta = `🟡 ESTADO ACTUAL: PRESUPUESTO PENDIENTE DE ACEPTAR (PRE-${datosExpediente.budget_id}). Continúa la conversación ya iniciada sobre este presupuesto. +Puedes resolver dudas sobre su contenido usando los datos de la sección "DATOS DEL PRESUPUESTO" de más abajo (qué incluye, precios, plazos, etc.). +⛔ NO ofrezcas fecha ni hora de visita bajo ningún concepto: el presupuesto TODAVÍA NO está aceptado. +Si el cliente pide cita, pregunta cuándo puede ir el técnico, o quiere que empecéis el trabajo, explícale con amabilidad que primero tiene que aceptar el presupuesto desde su portal: pulsando el enlace que se le envió, entrando en el presupuesto y pulsando "Aceptar Presupuesto". En cuanto lo acepte, podréis concretar la visita.`; + } else if (datosExpediente.intake_mode) { const faltantes = []; if (!datosExpediente.nombre_cliente) faltantes.push("su nombre completo"); if (!datosExpediente.direccion) faltantes.push("la dirección exacta de la visita (calle, número, código postal y población)"); @@ -2196,6 +2300,7 @@ ${reglaCobertura ? ` --- 🧾 CONDICIONES DE COBERTURA PARA ESTE SERVICIO (Compañía: ${datosExpediente.compania || 'no especificada'} · Gremio: ${datosExpediente.gremio || 'no especificado'}) --- ${reglaCobertura.text} Menciona esto SOLO si el cliente pregunta por costes, materiales, o qué cubre el seguro. No lo repitas si no viene a cuento.` : ''} +${datosPresupuestoTexto} --- ✅ HUECOS EXACTOS CALCULADOS POR EL SISTEMA --- ${agendaDisponibleTexto} @@ -2226,7 +2331,7 @@ Ejemplo: - 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. -${esPrimerMensaje ? ` +${esPrimerMensaje && !datosExpediente.budget_id ? ` --- 📌 NOTA --- Este es el primer mensaje: preséntate y menciona el aviso (#${datosExpediente.ref}).` : ''} @@ -5773,7 +5878,9 @@ async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente nombre_cliente: service.nombre_cliente || "", direccion: service.direccion || "", intake_mode: service.intake_mode || false, - intake_purpose: service.intake_purpose || "" + intake_purpose: service.intake_purpose || "", + budget_mode: service.budget_mode || false, + budget_id: service.budget_id || null }); if (!respuestaIA) return; @@ -6024,8 +6131,8 @@ app.post("/webhook/evolution", async (req, res) => { } // 🔍 BUSCAMOS EL EXPEDIENTE ACTIVO MÁS RECIENTE POR TELÉFONO REAL - const svcQ = await pool.query(` - SELECT + const svcQuerySQL = ` + SELECT s.id, s.service_ref, s.assigned_to, @@ -6050,6 +6157,11 @@ app.post("/webhook/evolution", async (req, res) => { WHEN LOWER(COALESCE(s.raw_data->>'intake_mode', 'false')) IN ('true', 't', '1') THEN true ELSE false END as intake_mode, + CASE + WHEN LOWER(COALESCE(s.raw_data->>'budget_mode', 'false')) IN ('true', 't', '1') THEN true + ELSE false + END as budget_mode, + NULLIF(s.raw_data->>'budget_id', '')::int as budget_id, CASE WHEN LOWER(COALESCE(s.raw_data->>'ia_paused', 'false')) IN ('true', 't', '1') THEN true ELSE false @@ -6084,11 +6196,65 @@ app.post("/webhook/evolution", async (req, res) => { (SELECT MAX(sc.created_at) FROM service_communications sc WHERE sc.scraped_id = s.id) DESC NULLS LAST, s.created_at DESC LIMIT 1 - `, [ownerId, cleanPhone]); + `; + + let svcQ = await pool.query(svcQuerySQL, [ownerId, cleanPhone]); if (svcQ.rowCount === 0) { - console.log(`⚠️ [WEBHOOK IA] No se encontró expediente activo para ${cleanPhone}`); - return; + // 💰 Sin expediente activo: ¿tiene un presupuesto nuestro sin convertir + // todavía con este mismo teléfono? Si es así, le abrimos un expediente + // "modo presupuesto" para que la IA pueda resolver dudas sobre él (y, en + // cuanto esté aceptado, asignar técnico y ofrecer cita como cualquier otro). + let presupuestoCreado = false; + try { + const budgetQ = await pool.query(` + SELECT * FROM budgets + WHERE owner_id = $1 AND status != 'converted' + AND RIGHT(REGEXP_REPLACE(COALESCE(client_phone, ''), '\\D', '', 'g'), 9) = $2 + ORDER BY created_at DESC LIMIT 1 + `, [ownerId, cleanPhone]); + + if (budgetQ.rowCount > 0) { + if (esAudioSinTexto && !fromMe) { + sendWhatsAppAuto(telefonoCliente, "🎙️ Ahora mismo no puedo escuchar audios, ¿me lo escribes en un mensaje de texto, por favor?", instanceName, false).catch(console.error); + return; + } + if (!fromMe) { + const budget = budgetQ.rows[0]; + const itemsTexto = Array.isArray(budget.items) + ? budget.items.map(i => `${i.qty || 1}x ${i.concept || ''}`).join("\n") + : ""; + const rawDataPresupuesto = { + "Nombre Cliente": budget.client_name || "", + "Teléfono": budget.client_phone || telefonoCliente, + "Dirección": budget.client_address || "", + "Descripción": `Presupuesto PRE-${budget.id}:\n${itemsTexto}`, + "Compañía": "Particular", + "Origen": "WhatsApp Presupuesto", + "budget_mode": true, + "budget_id": budget.id + }; + await pool.query( + `INSERT INTO scraped_services (owner_id, provider, service_ref, status, automation_status, raw_data) + VALUES ($1, 'MANUAL', $2, 'pending', 'manual', $3)`, + [ownerId, `PRE-${budget.id}`, JSON.stringify(rawDataPresupuesto)] + ); + console.log(`💰 [PRESUPUESTO] Expediente placeholder creado para PRE-${budget.id} (owner ${ownerId})`); + presupuestoCreado = true; + } + } + } catch (eBudgetLookup) { + console.error("❌ Error comprobando presupuesto pendiente:", eBudgetLookup.message); + } + + if (presupuestoCreado) { + svcQ = await pool.query(svcQuerySQL, [ownerId, cleanPhone]); + } + + if (svcQ.rowCount === 0) { + console.log(`⚠️ [WEBHOOK IA] No se encontró expediente activo para ${cleanPhone}`); + return; + } } const service = svcQ.rows[0];