Añade IA para presupuestos pendientes: resuelve dudas del cliente y solo ofrece cita si el presupues
This commit is contained in:
182
server.js
182
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];
|
||||
|
||||
Reference in New Issue
Block a user