diff --git a/app.js b/app.js index 43b18ae..cc78e22 100644 --- a/app.js +++ b/app.js @@ -17,6 +17,7 @@ const adminRoutes = require('./routes/admin'); const saasRoutes = require('./routes/saas'); const companyRoutes = require('./routes/company'); const apiRoutes = require('./routes/api'); +const qrRoutes = require('./routes/qr'); const app = express(); @@ -53,6 +54,7 @@ app.use('/admin', adminRoutes); app.use('/saas', saasRoutes); app.use('/company', companyRoutes); app.use('/api', apiRoutes); +app.use('/qr', qrRoutes); const { reportarError } = require('./config/errorReporter'); diff --git a/config/setup.js b/config/setup.js index 5e7c17f..1318510 100644 --- a/config/setup.js +++ b/config/setup.js @@ -1,4 +1,5 @@ const mysql = require('mysql2/promise'); +const crypto = require('crypto'); require('dotenv').config(); async function setup() { @@ -201,6 +202,7 @@ async function setup() { company_id INT NOT NULL, nombre VARCHAR(150) NOT NULL, codigo VARCHAR(50) NULL, + qr_token VARCHAR(64) NULL, descripcion TEXT NULL, categoria VARCHAR(100) DEFAULT 'general', foto_url VARCHAR(255) NULL, @@ -209,6 +211,7 @@ async function setup() { updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (company_id), INDEX (codigo), + INDEX (qr_token), FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); @@ -236,6 +239,7 @@ async function setup() { herramienta_id INT NULL, ticket_visita_id INT NULL, tipo_asignacion ENUM('dotacion_fija', 'visita_fsm') DEFAULT 'dotacion_fija', + qr_token VARCHAR(64) NULL, fecha_asignacion DATETIME DEFAULT CURRENT_TIMESTAMP, fecha_devolucion DATETIME NULL, estado ENUM('asignado', 'devuelto', 'observado') DEFAULT 'asignado', @@ -249,6 +253,7 @@ async function setup() { INDEX (company_id), INDEX (tecnico_id), INDEX (ticket_visita_id), + INDEX (qr_token), FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE, FOREIGN KEY (tecnico_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (set_id) REFERENCES herramientas_sets(id) ON DELETE SET NULL, @@ -264,6 +269,24 @@ async function setup() { 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'); + // Migraciones de tokens QR para escaneo e impresión de etiquetas + await migrateColumn('herramientas_sets', 'qr_token', 'qr_token VARCHAR(64) NULL AFTER codigo'); + await migrateColumn('tecnico_herramientas_asignaciones', 'qr_token', 'qr_token VARCHAR(64) NULL AFTER tipo_asignacion'); + + // Backfill de qr_token para sets que aún no tengan uno generado + const [setsSinToken] = await conn.query('SELECT id FROM herramientas_sets WHERE qr_token IS NULL'); + for (const s of setsSinToken) { + const token = crypto.randomBytes(12).toString('hex'); + await conn.query('UPDATE herramientas_sets SET qr_token = ? WHERE id = ?', [token, s.id]); + } + + // Backfill de qr_token para asignaciones sin token + const [asigSinToken] = await conn.query('SELECT id FROM tecnico_herramientas_asignaciones WHERE qr_token IS NULL'); + for (const a of asigSinToken) { + const token = crypto.randomBytes(12).toString('hex'); + await conn.query('UPDATE tecnico_herramientas_asignaciones SET qr_token = ? WHERE id = ?', [token, a.id]); + } + // 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/models/HerramientaAsignacion.js b/models/HerramientaAsignacion.js index 896d93d..c90eb44 100644 --- a/models/HerramientaAsignacion.js +++ b/models/HerramientaAsignacion.js @@ -1,4 +1,5 @@ const db = require('../config/db'); +const crypto = require('crypto'); class HerramientaAsignacion { static async findAll(companyId, filtros = {}) { @@ -70,6 +71,51 @@ class HerramientaAsignacion { return rows[0] || null; } + static async findByToken(token) { + if (!token) return null; + const [rows] = await db.query( + `SELECT tha.*, + u.nombre as tecnico_nombre, + u.email as tecnico_email, + c.name as company_name, + 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, + adm.nombre as asignado_por_nombre + FROM tecnico_herramientas_asignaciones tha + INNER JOIN users u ON tha.tecnico_id = u.id + INNER JOIN companies c ON tha.company_id = c.id + LEFT JOIN herramientas_sets hs ON tha.set_id = hs.id + LEFT JOIN herramientas h ON tha.herramienta_id = h.id + LEFT JOIN ticket_visitas tv ON tha.ticket_visita_id = tv.id + LEFT JOIN tickets t ON tv.ticket_id = t.id + LEFT JOIN users adm ON tha.asignado_por = adm.id + WHERE tha.qr_token = ?`, + [token] + ); + if (!rows[0]) return null; + const asig = rows[0]; + + if (asig.set_id) { + const HerramientaSet = require('./HerramientaSet'); + asig.herramientas = await HerramientaSet.getItems(asig.set_id); + } else if (asig.herramienta_id) { + const Herramienta = require('./Herramienta'); + const tool = await Herramienta.findById(asig.herramienta_id, asig.company_id); + asig.herramientas = tool ? [tool] : []; + } else { + asig.herramientas = []; + } + + return asig; + } + static async create(data) { const { company_id, @@ -81,14 +127,17 @@ class HerramientaAsignacion { fecha_asignacion = new Date(), observaciones_entrega = null, foto_entrega_url = null, + qr_token = null, asignado_por = null } = data; + const finalQrToken = qr_token || crypto.randomBytes(12).toString('hex'); + 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, 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] + (company_id, tecnico_id, set_id, herramienta_id, ticket_visita_id, tipo_asignacion, qr_token, 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, finalQrToken, fecha_asignacion, observaciones_entrega, foto_entrega_url, asignado_por] ); return res.insertId; } diff --git a/models/HerramientaSet.js b/models/HerramientaSet.js index 41aee6b..d3174ce 100644 --- a/models/HerramientaSet.js +++ b/models/HerramientaSet.js @@ -1,4 +1,5 @@ const db = require('../config/db'); +const crypto = require('crypto'); class HerramientaSet { static async findAll(companyId) { @@ -27,10 +28,45 @@ class HerramientaSet { ); if (!rows[0]) return null; const set = rows[0]; + + if (!set.qr_token) { + set.qr_token = crypto.randomBytes(12).toString('hex'); + await db.query('UPDATE herramientas_sets SET qr_token = ? WHERE id = ?', [set.qr_token, set.id]); + } + set.herramientas = await this.getItems(set.id); return set; } + static async findByToken(token) { + if (!token) return null; + const [rows] = await db.query( + `SELECT hs.*, c.name as company_name, c.plan as company_plan + FROM herramientas_sets hs + INNER JOIN companies c ON hs.company_id = c.id + WHERE hs.qr_token = ?`, + [token] + ); + if (!rows[0]) return null; + const set = rows[0]; + set.herramientas = await this.getItems(set.id); + + // Obtener asignación activa de custodia si existe + const [custodia] = await db.query( + `SELECT tha.*, u.nombre as tecnico_nombre, u.email as tecnico_email, + tv.titulo as visita_titulo, tv.ticket_id, t.asunto as ticket_asunto + FROM tecnico_herramientas_asignaciones tha + INNER JOIN users u ON tha.tecnico_id = u.id + LEFT JOIN ticket_visitas tv ON tha.ticket_visita_id = tv.id + LEFT JOIN tickets t ON tv.ticket_id = t.id + WHERE tha.set_id = ? AND tha.estado = 'asignado' + ORDER BY tha.id DESC LIMIT 1`, + [set.id] + ); + set.custodia = custodia[0] || null; + return set; + } + static async getItems(setId) { const [items] = await db.query( `SELECT h.*, hsi.cantidad @@ -51,13 +87,16 @@ class HerramientaSet { descripcion = null, categoria = 'general', foto_url = null, + qr_token = null, is_active = 1 } = data; + const finalQrToken = qr_token || crypto.randomBytes(12).toString('hex'); + const [res] = await db.query( - `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] + `INSERT INTO herramientas_sets (company_id, nombre, codigo, qr_token, descripcion, categoria, foto_url, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [company_id, nombre, codigo, finalQrToken, descripcion, categoria, foto_url, is_active] ); const setId = res.insertId; diff --git a/package-lock.json b/package-lock.json index 396bba3..7d1cce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "express-session": "^1.19.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "mysql2": "^3.22.3" + "mysql2": "^3.22.3", + "qrcode": "^1.5.4" } }, "node_modules/@types/node": { @@ -45,6 +46,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -160,6 +185,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -235,6 +298,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -253,6 +325,12 @@ "node": ">= 0.8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -306,6 +384,12 @@ "node": ">=0.12.18" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -489,6 +573,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -525,6 +622,15 @@ "is-property": "^1.0.2" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -649,6 +755,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -704,6 +819,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -956,6 +1083,42 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -965,6 +1128,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -975,6 +1147,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -988,6 +1169,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -1036,6 +1234,21 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1135,6 +1348,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1245,6 +1464,32 @@ "node": ">=10.0.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1321,11 +1566,72 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index ef2792c..f0ea967 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "express-session": "^1.19.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "mysql2": "^3.22.3" + "mysql2": "^3.22.3", + "qrcode": "^1.5.4" } } diff --git a/routes/admin.js b/routes/admin.js index 82a5786..02edd9f 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -9,6 +9,8 @@ const Herramienta = require('../models/Herramienta'); const HerramientaSet = require('../models/HerramientaSet'); const HerramientaAsignacion = require('../models/HerramientaAsignacion'); const { guardarFoto } = require('../config/uploader'); +const db = require('../config/db'); +const QRCode = require('qrcode'); router.get('/dashboard', isCompanyAdmin, async (req, res) => { try { @@ -401,4 +403,46 @@ router.post('/herramientas/devolver/:id', isCompanyAdmin, async (req, res) => { } }); +// Imprimir Etiqueta QR de Set / Caja de Herramientas +router.get('/herramientas/sets/:id/etiqueta', isCompanyAdmin, async (req, res) => { + try { + const companyId = req.session.user.company_id; + const set = await HerramientaSet.findById(req.params.id, companyId); + if (!set) { + req.flash('error_msg', 'Kit de herramientas no encontrado'); + return res.redirect('/admin/herramientas#tab-sets'); + } + + const proto = req.headers['x-forwarded-proto'] || req.protocol; + const host = req.headers['x-forwarded-host'] || req.get('host'); + const baseUrl = process.env.APP_URL || `${proto}://${host}`; + const scanUrl = `${baseUrl}/qr/set/${set.qr_token}`; + + const qrDataUrl = await QRCode.toDataURL(scanUrl, { + width: 260, + margin: 1, + color: { + dark: '#000000', + light: '#ffffff' + }, + errorCorrectionLevel: 'H' + }); + + const [companyRows] = await db.query('SELECT name FROM companies WHERE id = ?', [companyId]); + set.company_name = companyRows[0]?.name || 'Telcotronics'; + + res.render('admin/etiqueta_qr', { + title: `Etiqueta QR: ${set.nombre}`, + set, + herramientas: set.herramientas || [], + scanUrl, + qrDataUrl + }); + } catch (error) { + console.error('Error al generar etiqueta QR de kit:', error); + req.flash('error_msg', 'Error al generar etiqueta QR: ' + error.message); + res.redirect('/admin/herramientas#tab-sets'); + } +}); + module.exports = router; diff --git a/routes/qr.js b/routes/qr.js new file mode 100644 index 0000000..0cdc87e --- /dev/null +++ b/routes/qr.js @@ -0,0 +1,61 @@ +const express = require('express'); +const router = express.Router(); +const HerramientaSet = require('../models/HerramientaSet'); +const HerramientaAsignacion = require('../models/HerramientaAsignacion'); + +// Escaneo público de Set / Caja de herramientas +router.get('/set/:token', async (req, res) => { + try { + const { token } = req.params; + const set = await HerramientaSet.findByToken(token); + + if (!set) { + return res.status(404).render('public/qr_error', { + title: 'Caja o Kit no encontrado', + mensaje: 'El código QR escaneado no corresponde a ninguna caja de herramientas registrada en el sistema.' + }); + } + + res.render('public/qr_set', { + title: `Caja de Herramientas: ${set.nombre}`, + set, + herramientas: set.herramientas || [], + custodia: set.custodia + }); + } catch (error) { + console.error('Error al consultar QR de Set:', error); + res.status(500).render('public/qr_error', { + title: 'Error de Consulta', + mensaje: 'Ocurrió un error al cargar la información del kit de herramientas.' + }); + } +}); + +// Escaneo público de Asignación de custodia +router.get('/asignacion/:token', async (req, res) => { + try { + const { token } = req.params; + const asig = await HerramientaAsignacion.findByToken(token); + + if (!asig) { + return res.status(404).render('public/qr_error', { + title: 'Asignación no encontrada', + mensaje: 'El código QR escaneado no corresponde a ninguna asignación de equipamiento activa.' + }); + } + + res.render('public/qr_asignacion', { + title: `Custodia: ${asig.set_nombre || asig.herramienta_nombre}`, + asig, + herramientas: asig.herramientas || [] + }); + } catch (error) { + console.error('Error al consultar QR de Asignación:', error); + res.status(500).render('public/qr_error', { + title: 'Error de Consulta', + mensaje: 'Ocurrió un error al cargar la información de la asignación.' + }); + } +}); + +module.exports = router; diff --git a/scripts/test_qr_flow.js b/scripts/test_qr_flow.js new file mode 100644 index 0000000..b88d52c --- /dev/null +++ b/scripts/test_qr_flow.js @@ -0,0 +1,56 @@ +const HerramientaSet = require('../models/HerramientaSet'); +const HerramientaAsignacion = require('../models/HerramientaAsignacion'); +const QRCode = require('qrcode'); + +async function testQrFlow() { + console.log('=== TEST DE FLUJO DE CÓDIGOS QR Y MANIFIESTO HTML ==='); + + const companyId = 1; + + // 1. Obtener Sets existentes + console.log('1. Consultando sets de herramientas de Telcotronics...'); + const sets = await HerramientaSet.findAll(companyId); + if (sets.length === 0) { + throw new Error('No hay sets en Telcotronics para probar'); + } + + const testSet = sets[0]; + console.log(` ✓ Set encontrado: "${testSet.nombre}" (ID: ${testSet.id}, Código: ${testSet.codigo})`); + console.log(` ✓ Token QR: ${testSet.qr_token}`); + + if (!testSet.qr_token) { + throw new Error('El set no tiene qr_token'); + } + + // 2. Probar findByToken + console.log('2. Buscando set mediante findByToken(qr_token)...'); + const setByToken = await HerramientaSet.findByToken(testSet.qr_token); + if (!setByToken) { + throw new Error('findByToken retornó null'); + } + console.log(` ✓ Set recuperado: ${setByToken.nombre} | Empresa: ${setByToken.company_name} | Herramientas: ${setByToken.herramientas.length}`); + console.log(` ✓ Estado custodia actual: ${setByToken.custodia ? 'EN CUSTODIA de ' + setByToken.custodia.tecnico_nombre : 'DISPONIBLE EN BODEGA'}`); + + // 3. Generar código QR con la librería qrcode + console.log('3. Generando código QR en formato Data URL...'); + const scanUrl = `https://soporte.sigmac.app/qr/set/${testSet.qr_token}`; + const qrDataUrl = await QRCode.toDataURL(scanUrl, { + width: 260, + margin: 1, + color: { dark: '#000000', light: '#ffffff' } + }); + + if (!qrDataUrl.startsWith('data:image/png;base64,')) { + throw new Error('El QR generado no es un Data URL de PNG válido'); + } + console.log(` ✓ Código QR generado correctamente (longitud base64: ${qrDataUrl.length} bytes)`); + console.log(` ✓ URL destino del QR: ${scanUrl}`); + + console.log('=== TEST DE QR Y MANIFIESTO FINALIZADO EXITOSAMENTE ==='); + process.exit(0); +} + +testQrFlow().catch(err => { + console.error('ERROR EN TEST:', err); + process.exit(1); +}); diff --git a/views/admin/etiqueta_qr.ejs b/views/admin/etiqueta_qr.ejs new file mode 100644 index 0000000..93d0341 --- /dev/null +++ b/views/admin/etiqueta_qr.ejs @@ -0,0 +1,190 @@ + + +
+ + +<%= set.descripcion %>
+ <% } %> + + ++ Toca cada herramienta para marcarla en el checklist físico al revisar el contenido de la caja. +
+ + <% if (herramientas.length === 0) { %> +