Añade IA para presupuestos pendientes: resuelve dudas del cliente y solo ofrece cita si el presupues

This commit is contained in:
2026-09-12 18:46:19 +00:00
parent 2bff21204f
commit dbad17a7c6

182
server.js
View File

@@ -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];