From b361e4a9b4c878c6651e5634c426ba0e5b3d0d1e Mon Sep 17 00:00:00 2001 From: pablinux Date: Sun, 1 Nov 2026 03:11:33 -0500 Subject: [PATCH] feat(herramientas): agregar soporte de fotografias y evidencias para herramientas, kits y asignaciones de custodia --- .gitignore | 2 + config/setup.js | 10 +++ config/uploader.js | 59 ++++++++++++++ models/Herramienta.js | 11 +-- models/HerramientaAsignacion.js | 41 +++++++--- models/HerramientaSet.js | 9 ++- public/uploads/herramientas/.gitkeep | 0 routes/admin.js | 51 ++++++++++-- scripts/test_foto_flow.js | 91 +++++++++++++++++++++ views/admin/herramientas.ejs | 116 ++++++++++++++++++++++++--- views/agent/herramientas.ejs | 31 +++++++ 11 files changed, 379 insertions(+), 42 deletions(-) create mode 100644 config/uploader.js create mode 100644 public/uploads/herramientas/.gitkeep create mode 100644 scripts/test_foto_flow.js diff --git a/.gitignore b/.gitignore index 5a1b062..f4a7826 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ .env servers.conf *.log +public/uploads/herramientas/* +!public/uploads/herramientas/.gitkeep diff --git a/config/setup.js b/config/setup.js index 8edef40..5e7c17f 100644 --- a/config/setup.js +++ b/config/setup.js @@ -184,6 +184,7 @@ async function setup() { estado ENUM('operativo', 'en_mantenimiento', 'danado', 'baja') DEFAULT 'operativo', ubicacion_bodega VARCHAR(150) NULL, notas TEXT NULL, + foto_url VARCHAR(255) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (company_id), @@ -202,6 +203,7 @@ async function setup() { codigo VARCHAR(50) NULL, descripcion TEXT NULL, categoria VARCHAR(100) DEFAULT 'general', + foto_url VARCHAR(255) NULL, is_active TINYINT(1) DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -238,7 +240,9 @@ async function setup() { fecha_devolucion DATETIME NULL, estado ENUM('asignado', 'devuelto', 'observado') DEFAULT 'asignado', observaciones_entrega TEXT NULL, + foto_entrega_url VARCHAR(255) NULL, observaciones_devolucion TEXT NULL, + foto_devolucion_url VARCHAR(255) NULL, asignado_por INT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -254,6 +258,12 @@ async function setup() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // Migraciones de columnas para fotografías y evidencias + await migrateColumn('herramientas', 'foto_url', 'foto_url VARCHAR(255) NULL AFTER notas'); + await migrateColumn('herramientas_sets', 'foto_url', 'foto_url VARCHAR(255) NULL AFTER descripcion'); + await migrateColumn('tecnico_herramientas_asignaciones', 'foto_entrega_url', 'foto_entrega_url VARCHAR(255) NULL AFTER observaciones_entrega'); + await migrateColumn('tecnico_herramientas_asignaciones', 'foto_devolucion_url', 'foto_devolucion_url VARCHAR(255) NULL AFTER observaciones_devolucion'); + // Seed herramientas y sets iniciales para Telcotronics si no existen const [existingTools] = await conn.query('SELECT COUNT(*) as c FROM herramientas WHERE company_id = 1'); if (existingTools[0].c === 0) { diff --git a/config/uploader.js b/config/uploader.js new file mode 100644 index 0000000..5af4064 --- /dev/null +++ b/config/uploader.js @@ -0,0 +1,59 @@ +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +const UPLOADS_DIR = path.join(__dirname, '..', 'public', 'uploads', 'herramientas'); + +// Asegurar existencia del directorio de uploads +if (!fs.existsSync(UPLOADS_DIR)) { + fs.mkdirSync(UPLOADS_DIR, { recursive: true }); +} + +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif' +]; + +const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; + +/** + * Guarda una imagen subida mediante express-fileupload en public/uploads/herramientas/ + * @param {Object} fileObj - Objeto de archivo de req.files + * @param {string} prefix - Prefijo descriptivo del archivo (ej: 'herramienta', 'kit', 'entrega', 'devolucion') + * @returns {Promise} - Ruta pública (/uploads/herramientas/...) o null si no se proporcionó archivo + */ +async function guardarFoto(fileObj, prefix = 'herramienta') { + if (!fileObj || !fileObj.name || !fileObj.data) { + return null; + } + + const ext = path.extname(fileObj.name).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext) || !ALLOWED_MIME_TYPES.includes(fileObj.mimetype)) { + throw new Error('Formato de imagen no soportado. Formatos válidos: JPG, PNG, WEBP, GIF.'); + } + + // Límite de 8 MB + if (fileObj.size > 8 * 1024 * 1024) { + throw new Error('La imagen excede el límite máximo de 8MB.'); + } + + const randomSuffix = crypto.randomBytes(6).toString('hex'); + const safeFilename = `${prefix}_${Date.now()}_${randomSuffix}${ext}`; + const targetPath = path.join(UPLOADS_DIR, safeFilename); + + await new Promise((resolve, reject) => { + fileObj.mv(targetPath, (err) => { + if (err) return reject(err); + resolve(); + }); + }); + + return `/uploads/herramientas/${safeFilename}`; +} + +module.exports = { + guardarFoto, + UPLOADS_DIR +}; diff --git a/models/Herramienta.js b/models/Herramienta.js index a39de88..e937dd5 100644 --- a/models/Herramienta.js +++ b/models/Herramienta.js @@ -43,14 +43,15 @@ class Herramienta { modelo = null, estado = 'operativo', ubicacion_bodega = null, - notas = null + notas = null, + foto_url = null } = data; const [result] = await db.query( `INSERT INTO herramientas - (company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas] + (company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas, foto_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [company_id, nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas, foto_url] ); return result.insertId; } @@ -59,7 +60,7 @@ class Herramienta { const fields = []; const values = []; - const allowed = ['nombre', 'codigo', 'numero_serie', 'categoria', 'marca', 'modelo', 'estado', 'ubicacion_bodega', 'notas']; + const allowed = ['nombre', 'codigo', 'numero_serie', 'categoria', 'marca', 'modelo', 'estado', 'ubicacion_bodega', 'notas', 'foto_url']; for (const [key, val] of Object.entries(data)) { if (allowed.includes(key)) { fields.push(`${key} = ?`); diff --git a/models/HerramientaAsignacion.js b/models/HerramientaAsignacion.js index 8dc6cc0..896d93d 100644 --- a/models/HerramientaAsignacion.js +++ b/models/HerramientaAsignacion.js @@ -8,8 +8,10 @@ class HerramientaAsignacion { u.email as tecnico_email, hs.nombre as set_nombre, hs.codigo as set_codigo, + hs.foto_url as set_foto_url, h.nombre as herramienta_nombre, h.codigo as herramienta_codigo, + h.foto_url as herramienta_foto_url, tv.titulo as visita_titulo, tv.ticket_id, t.asunto as ticket_asunto, @@ -53,10 +55,15 @@ class HerramientaAsignacion { u.nombre as tecnico_nombre, u.email as tecnico_email, hs.nombre as set_nombre, - hs.codigo as set_codigo + hs.codigo as set_codigo, + hs.foto_url as set_foto_url, + h.nombre as herramienta_nombre, + h.codigo as herramienta_codigo, + h.foto_url as herramienta_foto_url FROM tecnico_herramientas_asignaciones tha INNER JOIN users u ON tha.tecnico_id = u.id LEFT JOIN herramientas_sets hs ON tha.set_id = hs.id + LEFT JOIN herramientas h ON tha.herramienta_id = h.id WHERE tha.id = ? AND tha.company_id = ?`, [id, companyId] ); @@ -73,27 +80,35 @@ class HerramientaAsignacion { tipo_asignacion = 'dotacion_fija', fecha_asignacion = new Date(), observaciones_entrega = null, + foto_entrega_url = null, asignado_por = null } = data; const [res] = await db.query( `INSERT INTO tecnico_herramientas_asignaciones - (company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, estado, observaciones_entrega, asignado_por) - VALUES (?, ?, ?, ?, ?, ?, ?, 'asignado', ?, ?)`, - [company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, observaciones_entrega, asignado_por] + (company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, estado, observaciones_entrega, foto_entrega_url, asignado_por) + VALUES (?, ?, ?, ?, ?, ?, ?, 'asignado', ?, ?, ?)`, + [company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, fecha_asignacion, observaciones_entrega, foto_entrega_url, asignado_por] ); return res.insertId; } - static async devolver(id, companyId, { observaciones_devolucion = null, estado = 'devuelto' }) { - await db.query( - `UPDATE tecnico_herramientas_asignaciones - SET estado = ?, - fecha_devolucion = NOW(), - observaciones_devolucion = ? - WHERE id = ? AND company_id = ?`, - [estado, observaciones_devolucion, id, companyId] - ); + static async devolver(id, companyId, { observaciones_devolucion = null, estado = 'devuelto', foto_devolucion_url = null }) { + let sql = `UPDATE tecnico_herramientas_asignaciones + SET estado = ?, + fecha_devolucion = NOW(), + observaciones_devolucion = ?`; + const params = [estado, observaciones_devolucion]; + + if (foto_devolucion_url) { + sql += ', foto_devolucion_url = ?'; + params.push(foto_devolucion_url); + } + + sql += ' WHERE id = ? AND company_id = ?'; + params.push(id, companyId); + + await db.query(sql, params); } static async delete(id, companyId) { diff --git a/models/HerramientaSet.js b/models/HerramientaSet.js index c4c61b6..41aee6b 100644 --- a/models/HerramientaSet.js +++ b/models/HerramientaSet.js @@ -50,13 +50,14 @@ class HerramientaSet { codigo = null, descripcion = null, categoria = 'general', + foto_url = null, is_active = 1 } = data; const [res] = await db.query( - `INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria, is_active) - VALUES (?, ?, ?, ?, ?, ?)`, - [company_id, nombre, codigo, descripcion, categoria, is_active] + `INSERT INTO herramientas_sets (company_id, nombre, codigo, descripcion, categoria, foto_url, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [company_id, nombre, codigo, descripcion, categoria, foto_url, is_active] ); const setId = res.insertId; @@ -75,7 +76,7 @@ class HerramientaSet { const fields = []; const values = []; - const allowed = ['nombre', 'codigo', 'descripcion', 'categoria', 'is_active']; + const allowed = ['nombre', 'codigo', 'descripcion', 'categoria', 'foto_url', 'is_active']; for (const [key, val] of Object.entries(data)) { if (allowed.includes(key)) { fields.push(`${key} = ?`); diff --git a/public/uploads/herramientas/.gitkeep b/public/uploads/herramientas/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.js b/routes/admin.js index 26bb1cc..82a5786 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -8,6 +8,7 @@ const Ticket = require('../models/Ticket'); const Herramienta = require('../models/Herramienta'); const HerramientaSet = require('../models/HerramientaSet'); const HerramientaAsignacion = require('../models/HerramientaAsignacion'); +const { guardarFoto } = require('../config/uploader'); router.get('/dashboard', isCompanyAdmin, async (req, res) => { try { @@ -192,6 +193,11 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => { return res.redirect('/admin/herramientas'); } try { + let foto_url = null; + if (req.files && req.files.foto) { + foto_url = await guardarFoto(req.files.foto, 'herramienta'); + } + await Herramienta.create({ company_id: req.session.user.company_id, nombre, @@ -202,7 +208,8 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => { modelo: modelo || null, estado: estado || 'operativo', ubicacion_bodega: ubicacion_bodega || null, - notas: notas || null + notas: notas || null, + foto_url }); req.flash('success_msg', `Herramienta "${nombre}" registrada correctamente`); res.redirect('/admin/herramientas#tab-herramientas'); @@ -217,7 +224,7 @@ router.post('/herramientas/create', isCompanyAdmin, async (req, res) => { router.post('/herramientas/edit/:id', isCompanyAdmin, async (req, res) => { const { nombre, codigo, numero_serie, categoria, marca, modelo, estado, ubicacion_bodega, notas } = req.body; try { - await Herramienta.update(req.params.id, req.session.user.company_id, { + const updateData = { nombre, codigo, numero_serie, @@ -227,7 +234,13 @@ router.post('/herramientas/edit/:id', isCompanyAdmin, async (req, res) => { estado, ubicacion_bodega, notas - }); + }; + + if (req.files && req.files.foto) { + updateData.foto_url = await guardarFoto(req.files.foto, 'herramienta'); + } + + await Herramienta.update(req.params.id, req.session.user.company_id, updateData); req.flash('success_msg', 'Herramienta actualizada exitosamente'); res.redirect('/admin/herramientas#tab-herramientas'); } catch (error) { @@ -265,12 +278,18 @@ router.post('/herramientas/sets/create', isCompanyAdmin, async (req, res) => { items = [herramientas_ids]; } + let foto_url = null; + if (req.files && req.files.foto) { + foto_url = await guardarFoto(req.files.foto, 'kit'); + } + await HerramientaSet.create({ company_id: req.session.user.company_id, nombre, codigo: codigo || null, descripcion: descripcion || null, - categoria: categoria || 'general' + categoria: categoria || 'general', + foto_url }, items); req.flash('success_msg', `Kit de herramientas "${nombre}" creado con ${items.length} instrumentos.`); @@ -291,13 +310,19 @@ router.post('/herramientas/sets/edit/:id', isCompanyAdmin, async (req, res) => { items = Array.isArray(herramientas_ids) ? herramientas_ids : (herramientas_ids ? [herramientas_ids] : []); } - await HerramientaSet.update(req.params.id, req.session.user.company_id, { + const updateData = { nombre, codigo, descripcion, categoria, is_active: is_active === '1' || is_active === 1 ? 1 : 0 - }, items); + }; + + if (req.files && req.files.foto) { + updateData.foto_url = await guardarFoto(req.files.foto, 'kit'); + } + + await HerramientaSet.update(req.params.id, req.session.user.company_id, updateData, items); req.flash('success_msg', 'Set / Kit actualizado exitosamente'); res.redirect('/admin/herramientas#tab-sets'); @@ -329,6 +354,11 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => { return res.redirect('/admin/herramientas'); } try { + let foto_entrega_url = null; + if (req.files && req.files.foto_entrega) { + foto_entrega_url = await guardarFoto(req.files.foto_entrega, 'entrega'); + } + await HerramientaAsignacion.create({ company_id: req.session.user.company_id, tecnico_id: parseInt(tecnico_id), @@ -336,6 +366,7 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => { herramienta_id: herramienta_id ? parseInt(herramienta_id) : null, tipo_asignacion: tipo_asignacion || 'dotacion_fija', observaciones_entrega: observaciones_entrega || 'Asignación administrativa de equipamiento', + foto_entrega_url, asignado_por: req.session.user.id }); req.flash('success_msg', 'Equipamiento asignado al técnico exitosamente'); @@ -351,9 +382,15 @@ router.post('/herramientas/asignar', isCompanyAdmin, async (req, res) => { router.post('/herramientas/devolver/:id', isCompanyAdmin, async (req, res) => { const { observaciones_devolucion, estado } = req.body; try { + let foto_devolucion_url = null; + if (req.files && req.files.foto_devolucion) { + foto_devolucion_url = await guardarFoto(req.files.foto_devolucion, 'devolucion'); + } + await HerramientaAsignacion.devolver(req.params.id, req.session.user.company_id, { observaciones_devolucion: observaciones_devolucion || 'Devuelto conforme a bodega', - estado: estado || 'devuelto' + estado: estado || 'devuelto', + foto_devolucion_url }); req.flash('success_msg', 'Devolución de herramientas registrada correctamente'); res.redirect('/admin/herramientas#tab-asignaciones'); diff --git a/scripts/test_foto_flow.js b/scripts/test_foto_flow.js new file mode 100644 index 0000000..f90649f --- /dev/null +++ b/scripts/test_foto_flow.js @@ -0,0 +1,91 @@ +const path = require('path'); +const Herramienta = require('../models/Herramienta'); +const HerramientaSet = require('../models/HerramientaSet'); +const HerramientaAsignacion = require('../models/HerramientaAsignacion'); +const db = require('../config/db'); + +async function testFotoFlow() { + console.log('=== TEST DE FLUJO DE HERRAMIENTAS Y FOTOGRAFÍAS ==='); + + const companyId = 1; + + // 1. Crear herramienta con foto simulada + console.log('1. Creando herramienta de prueba con foto_url...'); + const testToolId = await Herramienta.create({ + company_id: companyId, + nombre: 'Reflectómetro de Prueba OTDR', + codigo: 'TEST-FOTO-01', + numero_serie: 'SN-TEST-8899', + categoria: 'medicion', + marca: 'Anritsu', + modelo: 'MT9090A', + estado: 'operativo', + ubicacion_bodega: 'Bodega Central - Estante A1', + notas: 'Herramienta para verificar soporte fotográfico', + foto_url: '/uploads/herramientas/test_otdr.jpg' + }); + console.log(' ✓ Herramienta creada con ID:', testToolId); + + const fetchedTool = await Herramienta.findById(testToolId, companyId); + console.log(' ✓ Herramienta recuperada:', fetchedTool.nombre, '| foto_url:', fetchedTool.foto_url); + + // 2. Crear Set de Herramientas con foto simulada + console.log('2. Creando Set con foto_url...'); + const testSetId = await HerramientaSet.create({ + company_id: companyId, + nombre: 'Kit de Certificación Fotográfico', + codigo: 'SET-FOTO-01', + descripcion: 'Kit con evidencia fotográfica del maletín completo', + categoria: 'fibra_optica', + foto_url: '/uploads/herramientas/test_kit.jpg' + }, [testToolId]); + console.log(' ✓ Set creado con ID:', testSetId); + + const fetchedSet = await HerramientaSet.findById(testSetId, companyId); + console.log(' ✓ Set recuperado:', fetchedSet.nombre, '| foto_url:', fetchedSet.foto_url, '| items:', fetchedSet.herramientas.length); + + // 3. Crear Asignación a técnico con foto_entrega_url + console.log('3. Creando asignación de custodia con foto_entrega_url...'); + const [users] = await db.query("SELECT id FROM users WHERE company_id = ? AND rol IN ('agent', 'admin') LIMIT 1", [companyId]); + const tecnicoId = users[0].id; + + const asignacionId = await HerramientaAsignacion.create({ + company_id: companyId, + tecnico_id: tecnicoId, + set_id: testSetId, + tipo_asignacion: 'dotacion_fija', + observaciones_entrega: 'Entregado maletín precintado y verificado fotográficamente', + foto_entrega_url: '/uploads/herramientas/test_entrega.jpg', + asignado_por: tecnicoId + }); + console.log(' ✓ Asignación creada con ID:', asignacionId); + + const fetchedAsig = await HerramientaAsignacion.findById(asignacionId, companyId); + console.log(' ✓ Asignación recuperada:', fetchedAsig.set_nombre, '| foto_entrega_url:', fetchedAsig.foto_entrega_url); + + // 4. Registrar Devolución con foto_devolucion_url + console.log('4. Registrando devolución con foto_devolucion_url...'); + await HerramientaAsignacion.devolver(asignacionId, companyId, { + observaciones_devolucion: 'Reingresado a bodega con evidencia fotográfica', + estado: 'devuelto', + foto_devolucion_url: '/uploads/herramientas/test_devolucion.jpg' + }); + + const returnedAsig = await HerramientaAsignacion.findById(asignacionId, companyId); + console.log(' ✓ Devolución verificada:', returnedAsig.estado, '| foto_devolucion_url:', returnedAsig.foto_devolucion_url); + + // 5. Limpieza de datos de prueba + console.log('5. Limpiando datos de prueba...'); + await HerramientaAsignacion.delete(asignacionId, companyId); + await HerramientaSet.delete(testSetId, companyId); + await Herramienta.delete(testToolId, companyId); + console.log(' ✓ Datos de prueba eliminados correctamente.'); + + console.log('=== TODOS LOS TESTS DE PERSISTENCIA Y MODELOS PASARON EXITOSAMENTE ==='); + process.exit(0); +} + +testFotoFlow().catch(err => { + console.error('ERROR EN TEST:', err); + process.exit(1); +}); diff --git a/views/admin/herramientas.ejs b/views/admin/herramientas.ejs index d884d6d..98adf3f 100644 --- a/views/admin/herramientas.ejs +++ b/views/admin/herramientas.ejs @@ -146,6 +146,13 @@
+ <% if (s.foto_url) { %> +
+ + <%= s.nombre %> + +
+ <% } %>
<%= s.codigo || 'KIT-' + s.id %> @@ -171,8 +178,12 @@
    <% s.herramientas.forEach(h => { %>
  • -
    - +
    + <% if (h.foto_url) { %> + Foto + <% } else { %> + + <% } %> <%= h.nombre %> <% if (h.marca) { %>(<%= h.marca %>)<% } %>
    @@ -211,7 +222,7 @@