Anade backend de partes de trabajo
This commit is contained in:
188
server.js
188
server.js
@@ -813,6 +813,23 @@ async function autoUpdateDB() {
|
||||
is_internal BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 🧾 PARTES DE TRABAJO (texto redactado con IA + firma del cliente + PDF)
|
||||
CREATE TABLE IF NOT EXISTS work_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
scraped_id INT REFERENCES scraped_services(id) ON DELETE CASCADE,
|
||||
owner_id INT REFERENCES users(id) ON DELETE CASCADE,
|
||||
operator_id INT REFERENCES users(id) ON DELETE SET NULL,
|
||||
operator_name TEXT,
|
||||
client_name TEXT,
|
||||
client_phone TEXT,
|
||||
raw_text TEXT,
|
||||
final_text TEXT NOT NULL,
|
||||
signature_data TEXT,
|
||||
pdf_base64 TEXT,
|
||||
sent_to JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// PARCHE DE ACTUALIZACIÓN
|
||||
@@ -2951,6 +2968,48 @@ async function sendWhatsAppAuto(phone, message, instanceName, useDelay = false)
|
||||
}
|
||||
}
|
||||
|
||||
// 📎 Envía un documento (PDF) por WhatsApp vía Evolution API. Devuelve {ok, detail}
|
||||
// para poder ver el motivo exacto de un fallo (útil para depurar en caliente).
|
||||
async function sendWhatsAppMedia(phone, base64Pdf, fileName, caption, instanceName) {
|
||||
if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) {
|
||||
console.error("⚠️ WhatsApp omitido: Servidor Evolution no configurado.");
|
||||
return { ok: false, detail: "Evolution no configurado" };
|
||||
}
|
||||
if (!base64Pdf) return { ok: false, detail: "Sin PDF" };
|
||||
|
||||
try {
|
||||
const baseUrl = EVOLUTION_BASE_URL.replace(/\/$/, "");
|
||||
const url = `${baseUrl}/message/sendMedia/${instanceName}`;
|
||||
const cleanBase64 = base64Pdf.includes(',') ? base64Pdf.split(',').pop() : base64Pdf;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"apikey": EVOLUTION_API_KEY.trim()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
number: phone,
|
||||
mediatype: "document",
|
||||
mimetype: "application/pdf",
|
||||
fileName: fileName,
|
||||
caption: caption,
|
||||
media: cleanBase64
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => "");
|
||||
console.error("❌ Fallo enviando documento WhatsApp. Estado Evolution:", response.status, errText);
|
||||
return { ok: false, detail: `HTTP ${response.status}: ${errText.substring(0, 300)}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error("❌ Error de red enviando documento WhatsApp:", error.message);
|
||||
return { ok: false, detail: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 🔔 DISPARADOR DE EVENTOS DE WHATSAPP (CON MODO SEGURO)
|
||||
// ==========================================
|
||||
@@ -4990,6 +5049,135 @@ app.post("/services/:id/chat", authMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 🧾 PARTES DE TRABAJO (texto con IA + firma + PDF + envío WhatsApp)
|
||||
// ==========================================
|
||||
|
||||
// 1. Reescribe con IA el texto que escribe el operario, en un tono profesional
|
||||
app.post("/work-reports/improve", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!text || !text.toString().trim()) return res.status(400).json({ ok: false, error: "Falta el texto" });
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: OPENAI_MODEL || "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: "Eres un técnico administrativo que redacta partes de trabajo profesionales para una empresa de reparaciones y mantenimiento del hogar. Reescribe las notas que te da el operario en un texto claro, profesional, ordenado y objetivo, en español, describiendo el diagnóstico y el trabajo realizado. No inventes datos que no te den. No uses markdown, ni asteriscos, ni listas con guiones. Máximo 120 palabras." },
|
||||
{ role: "user", content: text.toString().trim() }
|
||||
],
|
||||
temperature: 0.4
|
||||
});
|
||||
|
||||
const improved = completion.choices?.[0]?.message?.content?.trim();
|
||||
if (!improved) return res.status(500).json({ ok: false, error: "La IA no ha devuelto ningún texto" });
|
||||
res.json({ ok: true, text: improved });
|
||||
} catch (e) {
|
||||
console.error("❌ Error mejorando parte de trabajo con IA:", e.message);
|
||||
res.status(500).json({ ok: false, error: "Error al contactar con la IA" });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Guarda un parte de trabajo ya generado (texto final + firma + PDF en base64)
|
||||
app.post("/services/:id/work-report", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { raw_text, final_text, signature_data, pdf_base64 } = req.body;
|
||||
|
||||
if (!final_text || !final_text.toString().trim()) return res.status(400).json({ ok: false, error: "Falta el texto del parte" });
|
||||
if (!pdf_base64) return res.status(400).json({ ok: false, error: "Falta el PDF generado" });
|
||||
|
||||
const svcQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1 AND owner_id=$2", [id, req.user.accountId]);
|
||||
if (svcQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" });
|
||||
const raw = svcQ.rows[0].raw_data || {};
|
||||
|
||||
const userQ = await pool.query("SELECT full_name FROM users WHERE id=$1", [req.user.sub]);
|
||||
const operatorName = userQ.rows[0]?.full_name || "Operario";
|
||||
|
||||
const clientName = raw["Nombre Cliente"] || raw["CLIENTE"] || "";
|
||||
const clientPhoneRaw = raw["Teléfono"] || raw["TELEFONO"] || raw["TELEFONOS"] || "";
|
||||
const clientPhone = extractValidPhone(clientPhoneRaw);
|
||||
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO work_reports (scraped_id, owner_id, operator_id, operator_name, client_name, client_phone, raw_text, final_text, signature_data, pdf_base64)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id, created_at`,
|
||||
[id, req.user.accountId, req.user.sub, operatorName, clientName, clientPhone, (raw_text || "").toString(), final_text.toString().trim(), signature_data || null, pdf_base64]
|
||||
);
|
||||
|
||||
res.json({ ok: true, report: { id: insert.rows[0].id, created_at: insert.rows[0].created_at, client_phone: clientPhone, client_name: clientName } });
|
||||
} catch (e) {
|
||||
console.error("❌ Error guardando parte de trabajo:", e);
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Lista el historial de partes de trabajo de un servicio (sin el PDF pesado)
|
||||
app.get("/services/:id/work-reports", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const q = await pool.query(
|
||||
`SELECT id, operator_name, final_text, client_phone, sent_to, created_at
|
||||
FROM work_reports WHERE scraped_id=$1 AND owner_id=$2 ORDER BY created_at DESC`,
|
||||
[id, req.user.accountId]
|
||||
);
|
||||
res.json({ ok: true, reports: q.rows });
|
||||
} catch (e) {
|
||||
console.error("❌ Error listando partes de trabajo:", e);
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Devuelve el PDF en base64 de un parte concreto (para verlo o reenviarlo)
|
||||
app.get("/work-reports/:id/pdf", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const q = await pool.query("SELECT pdf_base64, client_name FROM work_reports WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]);
|
||||
if (q.rowCount === 0) return res.status(404).json({ ok: false });
|
||||
res.json({ ok: true, pdf_base64: q.rows[0].pdf_base64, client_name: q.rows[0].client_name });
|
||||
} catch (e) {
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Envía por WhatsApp el PDF de un parte ya guardado. Si no se pasan teléfonos,
|
||||
// se envía automáticamente al teléfono del cliente guardado en el parte.
|
||||
app.post("/work-reports/:id/send", authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
let { phones } = req.body;
|
||||
|
||||
const q = await pool.query("SELECT * FROM work_reports WHERE id=$1 AND owner_id=$2", [id, req.user.accountId]);
|
||||
if (q.rowCount === 0) return res.status(404).json({ ok: false, error: "Parte no encontrado" });
|
||||
const report = q.rows[0];
|
||||
|
||||
if (!phones || !Array.isArray(phones) || phones.length === 0) {
|
||||
phones = report.client_phone ? [report.client_phone] : [];
|
||||
}
|
||||
phones = phones.map(p => extractValidPhone(p)).filter(p => p && p.length >= 9);
|
||||
if (phones.length === 0) return res.status(400).json({ ok: false, error: "No hay ningún teléfono válido al que enviar" });
|
||||
|
||||
const fileName = `Parte_Trabajo_${report.scraped_id}.pdf`;
|
||||
const caption = `📋 Parte de trabajo${report.client_name ? ` - ${report.client_name}` : ''}`;
|
||||
|
||||
const results = [];
|
||||
for (const p of phones) {
|
||||
const finalPhone = "34" + p;
|
||||
const sendRes = await sendWhatsAppMedia(finalPhone, report.pdf_base64, fileName, caption, `cliente_${req.user.accountId}`);
|
||||
results.push({ phone: finalPhone, ok: sendRes.ok, detail: sendRes.ok ? undefined : sendRes.detail });
|
||||
}
|
||||
|
||||
const sentOk = results.filter(r => r.ok).map(r => r.phone);
|
||||
if (sentOk.length > 0) {
|
||||
const prevSent = Array.isArray(report.sent_to) ? report.sent_to : [];
|
||||
const newSentTo = Array.from(new Set([...prevSent, ...sentOk]));
|
||||
await pool.query("UPDATE work_reports SET sent_to=$1 WHERE id=$2", [JSON.stringify(newSentTo), id]);
|
||||
}
|
||||
|
||||
res.json({ ok: sentOk.length > 0, results });
|
||||
} catch (e) {
|
||||
console.error("❌ Error enviando parte de trabajo por WhatsApp:", e);
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 🤖 WEBHOOK CON ESCUDO HUMANO, MEMORIA Y GESTIÓN DE URGENCIAS
|
||||
// 🧠 Ejecuta realmente el turno de la IA para un expediente (llamado tras el debounce)
|
||||
async function procesarTurnoIA({ ownerId, service, mensajeTexto, telefonoCliente, instanceName }) {
|
||||
|
||||
Reference in New Issue
Block a user