Anade backend de partes de trabajo

This commit is contained in:
2026-08-19 22:10:43 +00:00
parent 247e8a818d
commit cbd109013a

188
server.js
View File

@@ -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 }) {