diff --git a/server.js b/server.js index 353912d..d97ff01 100644 --- a/server.js +++ b/server.js @@ -2482,6 +2482,208 @@ app.get("/services/:id/logs", authMiddleware, async (req, res) => { } catch(e) { res.status(500).json({ ok: false }); } }); +// ========================================== +// 📄 MOTOR DE PRESUPUESTOS Y CATÁLOGO DE ARTÍCULOS +// ========================================== +pool.query(` + CREATE TABLE IF NOT EXISTS articles ( + id SERIAL PRIMARY KEY, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + price DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS budgets ( + id SERIAL PRIMARY KEY, + owner_id INT REFERENCES users(id) ON DELETE CASCADE, + client_phone TEXT, + client_name TEXT, + client_address TEXT, + items JSONB DEFAULT '[]', + subtotal DECIMAL(10,2) DEFAULT 0.00, + tax DECIMAL(10,2) DEFAULT 0.00, + total DECIMAL(10,2) DEFAULT 0.00, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() + ); +`).catch(console.error); + +// --- CATÁLOGO DE ARTÍCULOS --- +app.get("/articles", authMiddleware, async (req, res) => { + try { + const q = await pool.query("SELECT * FROM articles WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); + res.json({ ok: true, articles: q.rows }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +app.post("/articles", authMiddleware, async (req, res) => { + try { + const { name, price } = req.body; + await pool.query("INSERT INTO articles (owner_id, name, price) VALUES ($1, $2, $3)", [req.user.accountId, name, price]); + res.json({ ok: true }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +app.put("/articles/:id", authMiddleware, async (req, res) => { + try { + const { name, price } = req.body; + await pool.query("UPDATE articles SET name=$1, price=$2 WHERE id=$3 AND owner_id=$4", [name, price, req.params.id, req.user.accountId]); + res.json({ ok: true }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +// --- PRESUPUESTOS --- +app.get("/budgets", authMiddleware, async (req, res) => { + try { + const q = await pool.query(` + SELECT b.*, + s.status as linked_service_status, + st.name as linked_service_status_name + FROM budgets b + LEFT JOIN scraped_services s ON s.service_ref = 'PRE-' || b.id AND s.owner_id = b.owner_id + LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text + WHERE b.owner_id=$1 + ORDER BY b.created_at DESC + `, [req.user.accountId]); + res.json({ ok: true, budgets: q.rows }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +app.post("/budgets", authMiddleware, async (req, res) => { + try { + const { client_phone, client_name, client_address, items, subtotal, tax, total } = req.body; + await pool.query( + "INSERT INTO budgets (owner_id, client_phone, client_name, client_address, items, subtotal, tax, total) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [req.user.accountId, client_phone, client_name, client_address, JSON.stringify(items), subtotal, tax, total] + ); + res.json({ ok: true }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +app.patch("/budgets/:id/status", authMiddleware, async (req, res) => { + try { + await pool.query("UPDATE budgets SET status=$1 WHERE id=$2 AND owner_id=$3", [req.body.status, req.params.id, req.user.accountId]); + res.json({ ok: true }); + } catch(e) { res.status(500).json({ok: false}); } +}); + +app.delete("/budgets/:id", authMiddleware, async (req, res) => { + try { + const q = await pool.query("SELECT status FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); + if (q.rowCount === 0) return res.status(404).json({ok: false, error: "No encontrado"}); + + const status = q.rows[0].status; + + const sq = await pool.query(` + SELECT st.name as status_name + FROM scraped_services s + LEFT JOIN service_statuses st ON st.id::text = (s.raw_data->>'status_operativo')::text + WHERE s.service_ref = $1 AND s.owner_id = $2 + `, [`PRE-${req.params.id}`, req.user.accountId]); + + let isAnulado = false; + if (sq.rowCount > 0 && sq.rows[0].status_name && sq.rows[0].status_name.toLowerCase().includes('anulado')) { + isAnulado = true; + } + + if ((status === 'accepted' || status === 'converted') && !isAnulado) { + return res.status(400).json({ok: false, error: "Para poder borrar un presupuesto, el servicio primero debe estar anulado."}); + } + + await pool.query("DELETE FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); + res.json({ ok: true }); + } catch(e) { + res.status(500).json({ok: false, error: "Error interno"}); + } +}); + +// Convertir Presupuesto en Servicio Activo (CON SOPORTE RED INTERNA Y TRAZABILIDAD) +app.post("/budgets/:id/convert", authMiddleware, async (req, res) => { + try { + const { date, time, guild_id, assigned_to, use_automation } = req.body; + const bq = await pool.query("SELECT * FROM budgets WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); + if (bq.rowCount === 0) return res.status(404).json({ok: false}); + const budget = bq.rows[0]; + + const rawData = { + "Nombre Cliente": budget.client_name, + "Teléfono": budget.client_phone, + "Dirección": budget.client_address, + "Compañía": "Particular", + "Descripción": "PRESUPUESTO ACEPTADO.\n" + budget.items.map(i => `${i.qty}x ${i.concept}`).join("\n"), + "guild_id": guild_id || null, + "assigned_to": assigned_to || null, + "scheduled_date": date || "", + "scheduled_time": time || "" + }; + + const insertSvc = await pool.query( + "INSERT INTO scraped_services (owner_id, provider, service_ref, status, automation_status, assigned_to, raw_data) VALUES ($1, 'particular', $2, 'pending', 'manual', $3, $4) RETURNING id", + [ + req.user.accountId, + `PRE-${budget.id}`, + assigned_to || null, + JSON.stringify(rawData) + ] + ); + + const newServiceId = insertSvc.rows[0].id; + + await pool.query("UPDATE budgets SET status='converted' WHERE id=$1", [budget.id]); + await pool.query( + "INSERT INTO service_financials (scraped_id, amount, payment_method) VALUES ($1, $2, 'Pendiente')", + [newServiceId, budget.total] + ); + + if (use_automation && guild_id) { + const cpMatch = budget.client_address ? budget.client_address.match(/\b\d{5}\b/) : null; + const cp = cpMatch ? cpMatch[0] : "00000"; + + const port = process.env.PORT || 3000; + const autoUrl = `http://127.0.0.1:${port}/providers/automate/${newServiceId}`; + + fetch(autoUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': req.headers.authorization + }, + body: JSON.stringify({ guild_id, cp, useDelay: false }) + }).catch(e => console.error("Error lanzando automatización interna:", e)); + + if (budget.client_phone) { + const msg = `✅ *PRESUPUESTO ACEPTADO*\n\nHola ${budget.client_name}, confirmamos la aceptación del presupuesto por un total de *${budget.total}€*.\n\nEn breve un técnico se pondrá en contacto contigo para agendar la cita. ¡Gracias por confiar en nosotros!`; + sendWhatsAppAuto(budget.client_phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error); + } + } + else if (budget.client_phone && date && time) { + const [y, m, d] = date.split('-'); + const msg = `✅ *PRESUPUESTO ACEPTADO*\n\nHola ${budget.client_name}, confirmamos la aceptación del presupuesto por un total de *${budget.total}€*.\n\nEl servicio ha sido agendado para el *${d}/${m}/${y} a las ${time}*. ¡Gracias por confiar en nosotros!`; + sendWhatsAppAuto(budget.client_phone, msg, `cliente_${req.user.accountId}`, false).catch(console.error); + + if (assigned_to) { + const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%asignado%' LIMIT 1", [req.user.accountId]); + if (statusQ.rowCount > 0) { + rawData.status_operativo = statusQ.rows[0].id; + await pool.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(rawData), newServiceId]); + } + } + } + + // --- INICIO TRAZABILIDAD --- + if (typeof registrarMovimiento === "function") { + await registrarMovimiento(newServiceId, req.user.sub, "Aviso Creado", `Servicio generado a raíz del presupuesto aceptado #PRE-${budget.id}.`); + } + // --- FIN TRAZABILIDAD --- + + res.json({ ok: true }); + } catch(e) { + console.error("Error convirtiendo presupuesto:", e); + res.status(500).json({ok: false}); + } +}); + // ========================================== // 🕒 EL RELOJ DEL SISTEMA (Ejecutar cada minuto) // ==========================================