diff --git a/package-lock.json b/package-lock.json index ee2acc6..f98beed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "express": "^5.2.1", "fast-xml-parser": "^5.3.6", "multer": "^2.0.2", + "ssh2": "^1.17.0", "xlsx": "^0.18.5" }, "devDependencies": { @@ -990,6 +991,15 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1020,6 +1030,15 @@ ], "license": "MIT" }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/better-sqlite3": { "version": "12.6.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", @@ -1108,6 +1127,15 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1265,6 +1293,20 @@ "node": ">=6.6.0" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -1993,6 +2035,13 @@ "node": ">= 0.6" } }, + "node_modules/nan": { + "version": "2.26.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", + "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2131,6 +2180,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2593,6 +2643,23 @@ "node": ">=0.8" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2747,6 +2814,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2797,6 +2870,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 269ef3a..44fdee2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "express": "^5.2.1", "fast-xml-parser": "^5.3.6", "multer": "^2.0.2", + "ssh2": "^1.17.0", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/src/index.js b/src/index.js index 0b708cd..bb59ce4 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ const express = require('express'); const path = require('path'); const { createDatabase } = require('./services/database'); const { startWatcher } = require('./services/watcher'); +const { startSyncInterval, syncXmls, SFTP_CONFIG } = require('./services/sftp-sync'); const { facturasRouter } = require('./routes/facturas'); const { articulosRouter } = require('./routes/articulos'); const { catalogoRouter } = require('./routes/catalogo'); @@ -40,8 +41,29 @@ const xmlFolder = db.prepare("SELECT valor FROM configuracion WHERE clave = 'xml if (xmlFolder) { const folderPath = path.resolve(xmlFolder.valor); startWatcher(db, folderPath); + + // Start SFTP sync (every 5 minutes) + const sftpPassword = db.prepare("SELECT valor FROM configuracion WHERE clave = 'sftp_password'").get(); + if (sftpPassword) { + SFTP_CONFIG.password = sftpPassword.valor; + startSyncInterval(folderPath, 5); + } } +// API endpoint to trigger manual sync +app.post('/api/sync', (req, res) => { + const folder = db.prepare("SELECT valor FROM configuracion WHERE clave = 'xml_folder'").get(); + if (!folder) return res.status(400).json({ error: 'xml_folder no configurado' }); + + const pw = db.prepare("SELECT valor FROM configuracion WHERE clave = 'sftp_password'").get(); + if (pw) SFTP_CONFIG.password = pw.valor; + + syncXmls(path.resolve(folder.valor), (err, files) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ success: true, downloaded: files || [] }); + }); +}); + app.listen(PORT, '0.0.0.0', () => { console.log(`Portal Refaccionaria running at http://0.0.0.0:${PORT}`); }); diff --git a/src/public/css/styles.css b/src/public/css/styles.css index 8bf64bb..97eddb4 100644 --- a/src/public/css/styles.css +++ b/src/public/css/styles.css @@ -307,6 +307,15 @@ input[type="text"]::placeholder { background: #16a34a; } +.btn-danger { + background: #dc2626; + color: #ffffff; +} + +.btn-danger:hover { + background: #b91c1c; +} + .btn-sm { height: 30px; padding: 0 10px; @@ -881,3 +890,103 @@ input[type="checkbox"] { flex-wrap: wrap; } } + +/* ============================================ + Label Editor + ============================================ */ +.label-editor-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; +} + +.label-editor-controls .form-group { + margin-bottom: 12px; +} + +.label-editor-controls .form-group label { + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} + +.label-editor-controls .form-group input[type="number"] { + width: 90px; +} + +.label-editor-controls .form-row { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.label-editor-controls .form-row .form-group { + flex: 1; +} + +.label-editor-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.label-editor-section:last-child { + border-bottom: none; +} + +.label-editor-section h4 { + margin: 0 0 10px 0; + font-size: 14px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.label-preview-panel { + position: sticky; + top: 24px; +} + +.label-preview-panel h3 { + margin: 0 0 12px 0; +} + +.label-canvas-wrapper { + background: #f8fafc; + border: 2px dashed var(--border); + border-radius: 8px; + padding: 24px; + display: flex; + justify-content: center; + align-items: center; +} + +.label-canvas-wrapper canvas { + border: 1px solid #cbd5e1; + background: #ffffff; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.label-editor-actions { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.checkbox-group { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; +} + +@media (max-width: 900px) { + .label-editor-layout { + grid-template-columns: 1fr; + } +} diff --git a/src/public/index.html b/src/public/index.html index d114353..69ab39e 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -28,6 +28,10 @@ 📋 Catálogo + + 🏷️ + Estilo Etiqueta + ⚙️ Configuración diff --git a/src/public/js/app.js b/src/public/js/app.js index f3db88c..b40d302 100644 --- a/src/public/js/app.js +++ b/src/public/js/app.js @@ -111,6 +111,9 @@ case 'catalogo': renderCatalogo(); break; + case 'etiqueta-editor': + renderEtiquetaEditor(); + break; case 'config': renderConfig(); break; @@ -667,7 +670,8 @@ '' + escapeHtml(item.no_parte_proveedor) + '' + '' + escapeHtml(item.no_parte_intercambio) + '' + '' + - '' + + ' ' + + '' + '' + ''; }).join(''); @@ -688,6 +692,22 @@ startInlineEdit(btn.closest('tr')); }); }); + + // Attach delete handlers + container.querySelectorAll('.cat-btn-delete').forEach(function (btn) { + btn.addEventListener('click', async function () { + var row = btn.closest('tr'); + var parteProveedor = row.getAttribute('data-parte'); + if (!confirm('¿Eliminar el intercambio para "' + parteProveedor + '"?')) return; + try { + await fetchJSON('/api/catalogo/' + encodeURIComponent(parteProveedor), { method: 'DELETE' }); + showToast('Intercambio eliminado', 'success'); + loadCatalogo(); + } catch (err) { + showToast('Error al eliminar: ' + err.message, 'error'); + } + }); + }); } catch (err) { container.innerHTML = '
' + @@ -799,6 +819,231 @@ } } + // ============================================ + // VIEW: Label Editor + // ============================================ + var labelDefaults = { + label_width: 2, label_height: 1, + label_desc_x: 30, label_desc_y: 20, label_desc_font: 28, + label_inter_x: 30, label_inter_y: 60, label_inter_font: 22, + label_barcode_x: 30, label_barcode_y: 95, label_barcode_height: 50, + label_barcode_show: 1 + }; + + async function renderEtiquetaEditor() { + showLoading(); + try { + var config = await fetchJSON('/api/config'); + var vals = {}; + for (var k in labelDefaults) { + vals[k] = config[k] !== undefined ? parseFloat(config[k]) : labelDefaults[k]; + } + + function numInput(id, label, value) { + return '
' + + '' + + '' + + '
'; + } + + contentEl.innerHTML = + '' + + '
' + + '
' + + '
' + + '

Tama\u00f1o de Etiqueta

' + + '
' + + numInput('le-width', 'Ancho (pulg)', vals.label_width) + + numInput('le-height', 'Alto (pulg)', vals.label_height) + + '
' + + '
' + + '
' + + '

Descripci\u00f3n

' + + '
' + + numInput('le-desc-x', 'Pos X', vals.label_desc_x) + + numInput('le-desc-y', 'Pos Y', vals.label_desc_y) + + numInput('le-desc-font', 'Fuente', vals.label_desc_font) + + '
' + + '
' + + '
' + + '

Intercambio

' + + '
' + + numInput('le-inter-x', 'Pos X', vals.label_inter_x) + + numInput('le-inter-y', 'Pos Y', vals.label_inter_y) + + numInput('le-inter-font', 'Fuente', vals.label_inter_font) + + '
' + + '
' + + '
' + + '

C\u00f3digo de Barras

' + + '
' + + numInput('le-bar-x', 'Pos X', vals.label_barcode_x) + + numInput('le-bar-y', 'Pos Y', vals.label_barcode_y) + + numInput('le-bar-height', 'Altura', vals.label_barcode_height) + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '

Vista Previa

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
'; + + // Attach events + var inputIds = ['le-width', 'le-height', 'le-desc-x', 'le-desc-y', 'le-desc-font', + 'le-inter-x', 'le-inter-y', 'le-inter-font', + 'le-bar-x', 'le-bar-y', 'le-bar-height']; + inputIds.forEach(function (id) { + document.getElementById(id).addEventListener('input', drawLabelPreview); + }); + document.getElementById('le-bar-show').addEventListener('change', drawLabelPreview); + + document.getElementById('le-btn-save').addEventListener('click', saveLabelStyle); + document.getElementById('le-btn-reset').addEventListener('click', function () { + for (var k in labelDefaults) { + var el = getEditorInput(k); + if (el) { + if (el.type === 'checkbox') el.checked = !!labelDefaults[k]; + else el.value = labelDefaults[k]; + } + } + drawLabelPreview(); + showToast('Valores restablecidos (no guardados a\u00fan)', 'warning'); + }); + + drawLabelPreview(); + } catch (err) { + contentEl.innerHTML = + '' + + '
⚠️
' + + '

Error: ' + escapeHtml(err.message) + '

'; + } + } + + function getEditorInput(configKey) { + var map = { + label_width: 'le-width', label_height: 'le-height', + label_desc_x: 'le-desc-x', label_desc_y: 'le-desc-y', label_desc_font: 'le-desc-font', + label_inter_x: 'le-inter-x', label_inter_y: 'le-inter-y', label_inter_font: 'le-inter-font', + label_barcode_x: 'le-bar-x', label_barcode_y: 'le-bar-y', + label_barcode_height: 'le-bar-height', label_barcode_show: 'le-bar-show' + }; + return document.getElementById(map[configKey]); + } + + function getEditorValues() { + return { + label_width: parseFloat(document.getElementById('le-width').value) || 2, + label_height: parseFloat(document.getElementById('le-height').value) || 1, + label_desc_x: parseInt(document.getElementById('le-desc-x').value) || 0, + label_desc_y: parseInt(document.getElementById('le-desc-y').value) || 0, + label_desc_font: parseInt(document.getElementById('le-desc-font').value) || 20, + label_inter_x: parseInt(document.getElementById('le-inter-x').value) || 0, + label_inter_y: parseInt(document.getElementById('le-inter-y').value) || 0, + label_inter_font: parseInt(document.getElementById('le-inter-font').value) || 18, + label_barcode_x: parseInt(document.getElementById('le-bar-x').value) || 0, + label_barcode_y: parseInt(document.getElementById('le-bar-y').value) || 0, + label_barcode_height: parseInt(document.getElementById('le-bar-height').value) || 40, + label_barcode_show: document.getElementById('le-bar-show').checked ? 1 : 0 + }; + } + + function drawLabelPreview() { + var canvas = document.getElementById('le-canvas'); + if (!canvas) return; + var v = getEditorValues(); + + // 203 DPI = 8 dots per mm. Scale: 1 ZPL dot = 1.5 canvas pixels for good visibility + var scale = 1.5; + var widthDots = Math.round(v.label_width * 203); + var heightDots = Math.round(v.label_height * 203); + canvas.width = Math.round(widthDots * scale); + canvas.height = Math.round(heightDots * scale); + + var ctx = canvas.getContext('2d'); + // White background + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Border + ctx.strokeStyle = '#e2e8f0'; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, canvas.width, canvas.height); + + // Description + var descFontPx = Math.round(v.label_desc_font * scale * 0.85); + ctx.fillStyle = '#1e293b'; + ctx.font = 'bold ' + descFontPx + 'px monospace'; + ctx.fillText('BALATA FRENO DELANTERO', v.label_desc_x * scale, (v.label_desc_y + v.label_desc_font) * scale); + + // Intercambio + var interFontPx = Math.round(v.label_inter_font * scale * 0.85); + ctx.font = interFontPx + 'px monospace'; + ctx.fillStyle = '#475569'; + ctx.fillText('Int: D1234-XYZ', v.label_inter_x * scale, (v.label_inter_y + v.label_inter_font) * scale); + + // Barcode + if (v.label_barcode_show) { + var bx = v.label_barcode_x * scale; + var by = v.label_barcode_y * scale; + var bh = v.label_barcode_height * scale; + ctx.fillStyle = '#1e293b'; + // Draw simple barcode pattern + var barWidth = 2 * scale; + var pattern = [3,1,2,1,1,2,3,1,1,1,2,1,3,1,1,2,2,1,1,1,3,1,2,1,1,2,1,1,3,1,1,1,2,3,1,1,2,1,1,2,3,1]; + var x = bx; + for (var i = 0; i < pattern.length; i++) { + if (i % 2 === 0) { + ctx.fillRect(x, by, pattern[i] * barWidth, bh); + } + x += pattern[i] * barWidth; + } + // Barcode text below + ctx.font = (10 * scale) + 'px monospace'; + ctx.fillText('ABC-12345', bx, by + bh + 12 * scale); + } + } + + async function saveLabelStyle() { + var btn = document.getElementById('le-btn-save'); + btn.disabled = true; + btn.textContent = 'Guardando...'; + + var vals = getEditorValues(); + var payload = {}; + for (var k in vals) { + payload[k] = String(vals[k]); + } + + try { + await fetchJSON('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + showToast('Estilo de etiqueta guardado', 'success'); + } catch (err) { + showToast('Error al guardar: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Guardar Estilo'; + } + } + // ============================================ // VIEW: Configuration // ============================================ diff --git a/src/routes/articulos.js b/src/routes/articulos.js index adae3fd..a8d06b9 100644 --- a/src/routes/articulos.js +++ b/src/routes/articulos.js @@ -1,5 +1,7 @@ const { Router } = require('express'); +const RFC_RECEPTOR = 'ROEM691011EZ4'; + function articulosRouter(db) { const router = Router(); @@ -11,12 +13,13 @@ function articulosRouter(db) { SELECT c.*, f.nombre_emisor, f.fecha, f.uuid as factura_uuid FROM conceptos c JOIN facturas f ON f.id = c.factura_id - WHERE c.descripcion LIKE ? + WHERE f.rfc_receptor = ? + AND (c.descripcion LIKE ? OR c.no_identificacion LIKE ? - OR c.intercambio LIKE ? + OR c.intercambio LIKE ?) ORDER BY f.fecha DESC LIMIT 200 - `).all(`%${q}%`, `%${q}%`, `%${q}%`); + `).all(RFC_RECEPTOR, `%${q}%`, `%${q}%`, `%${q}%`); res.json(articulos); }); diff --git a/src/routes/catalogo.js b/src/routes/catalogo.js index c8060a8..259f7f9 100644 --- a/src/routes/catalogo.js +++ b/src/routes/catalogo.js @@ -33,6 +33,14 @@ function catalogoRouter(db) { res.json({ success: true }); }); + router.delete('/:noParteProveedor', (req, res) => { + const result = db.prepare('DELETE FROM catalogo_intercambios WHERE no_parte_proveedor = ?').run(req.params.noParteProveedor); + if (result.changes === 0) return res.status(404).json({ error: 'Intercambio no encontrado' }); + // Also clear intercambio from conceptos that reference this part + db.prepare('UPDATE conceptos SET intercambio = NULL WHERE no_identificacion = ?').run(req.params.noParteProveedor); + res.json({ success: true }); + }); + router.post('/', (req, res) => { const { no_parte_proveedor, no_parte_intercambio } = req.body; db.prepare( diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js index 1f704fc..771be3c 100644 --- a/src/routes/dashboard.js +++ b/src/routes/dashboard.js @@ -1,11 +1,13 @@ const { Router } = require('express'); +const RFC_RECEPTOR = 'ROEM691011EZ4'; + function dashboardRouter(db) { const router = Router(); router.get('/', (req, res) => { // Recent invoices: last day based on most recent invoice date in DB - const latest = db.prepare('SELECT MAX(fecha) as max_fecha FROM facturas').get(); + const latest = db.prepare('SELECT MAX(fecha) as max_fecha FROM facturas WHERE rfc_receptor = ?').get(RFC_RECEPTOR); let recientes = []; if (latest && latest.max_fecha) { const maxDate = latest.max_fecha.substring(0, 10); @@ -13,10 +15,10 @@ function dashboardRouter(db) { SELECT f.*, COUNT(c.id) as num_conceptos FROM facturas f LEFT JOIN conceptos c ON c.factura_id = f.id - WHERE f.fecha >= ? + WHERE f.fecha >= ? AND f.rfc_receptor = ? GROUP BY f.id ORDER BY f.fecha DESC - `).all(maxDate); + `).all(maxDate, RFC_RECEPTOR); } // Top suppliers by invoice count @@ -24,10 +26,11 @@ function dashboardRouter(db) { SELECT nombre_emisor, rfc_emisor, COUNT(*) as total_facturas, SUM(total) as monto_total FROM facturas + WHERE rfc_receptor = ? GROUP BY rfc_emisor ORDER BY total_facturas DESC LIMIT 10 - `).all(); + `).all(RFC_RECEPTOR); // Summary stats const stats = db.prepare(` @@ -35,9 +38,14 @@ function dashboardRouter(db) { COUNT(DISTINCT rfc_emisor) as total_proveedores, COALESCE(SUM(total), 0) as monto_total FROM facturas - `).get(); + WHERE rfc_receptor = ? + `).get(RFC_RECEPTOR); - const totalArticulos = db.prepare('SELECT COUNT(*) as total FROM conceptos').get(); + const totalArticulos = db.prepare(` + SELECT COUNT(*) as total FROM conceptos c + JOIN facturas f ON f.id = c.factura_id + WHERE f.rfc_receptor = ? + `).get(RFC_RECEPTOR); res.json({ recientes, diff --git a/src/routes/facturas.js b/src/routes/facturas.js index 1f46120..1f6b1e6 100644 --- a/src/routes/facturas.js +++ b/src/routes/facturas.js @@ -1,5 +1,7 @@ const { Router } = require('express'); +const RFC_RECEPTOR = 'ROEM691011EZ4'; + function facturasRouter(db) { const router = Router(); @@ -10,8 +12,8 @@ function facturasRouter(db) { FROM facturas f LEFT JOIN conceptos c ON c.factura_id = f.id `; - const conditions = []; - const params = []; + const conditions = ['f.rfc_receptor = ?']; + const params = [RFC_RECEPTOR]; if (proveedor) { conditions.push('f.nombre_emisor LIKE ?'); @@ -30,9 +32,7 @@ function facturasRouter(db) { params.push(`%${q}%`, `%${q}%`); } - if (conditions.length > 0) { - sql += ' WHERE ' + conditions.join(' AND '); - } + sql += ' WHERE ' + conditions.join(' AND '); sql += ' GROUP BY f.id ORDER BY f.fecha DESC'; const facturas = db.prepare(sql).all(...params); @@ -40,7 +40,7 @@ function facturasRouter(db) { }); router.get('/:id', (req, res) => { - const factura = db.prepare('SELECT * FROM facturas WHERE id = ?').get(req.params.id); + const factura = db.prepare('SELECT * FROM facturas WHERE id = ? AND rfc_receptor = ?').get(req.params.id, RFC_RECEPTOR); if (!factura) return res.status(404).json({ error: 'Factura no encontrada' }); const conceptos = db.prepare('SELECT * FROM conceptos WHERE factura_id = ?').all(factura.id); diff --git a/src/routes/print.js b/src/routes/print.js index 000f687..86db35e 100644 --- a/src/routes/print.js +++ b/src/routes/print.js @@ -18,9 +18,21 @@ function printRouter(db) { const zebraIp = config.zebra_ip; const zebraPort = parseInt(config.zebra_port || '9100', 10); + // Build label style from config + const style = {}; + const styleKeys = [ + 'label_width', 'label_height', + 'label_desc_x', 'label_desc_y', 'label_desc_font', + 'label_inter_x', 'label_inter_y', 'label_inter_font', + 'label_barcode_x', 'label_barcode_y', 'label_barcode_height', 'label_barcode_show' + ]; + for (const key of styleKeys) { + if (config[key] !== undefined) style[key] = parseFloat(config[key]); + } + let allZpl = ''; for (const article of articles) { - allZpl += generateLabel(article, article.quantity || 1) + '\n'; + allZpl += generateLabel(article, article.quantity || 1, style) + '\n'; } try { diff --git a/src/services/cfdi-parser.js b/src/services/cfdi-parser.js index afcda4b..f1deb4f 100644 --- a/src/services/cfdi-parser.js +++ b/src/services/cfdi-parser.js @@ -19,6 +19,7 @@ function parseCfdiXml(xml) { const comprobante = parsed['cfdi:Comprobante']; const emisor = comprobante['cfdi:Emisor']; + const receptor = comprobante['cfdi:Receptor']; const conceptosNode = comprobante['cfdi:Conceptos']['cfdi:Concepto']; const complemento = comprobante['cfdi:Complemento']; @@ -39,6 +40,8 @@ function parseCfdiXml(xml) { uuid, rfcEmisor: emisor['@_Rfc'], nombreEmisor: emisor['@_Nombre'], + rfcReceptor: receptor['@_Rfc'], + nombreReceptor: receptor['@_Nombre'], fecha: comprobante['@_Fecha'], subtotal: parseFloat(comprobante['@_SubTotal']), total: parseFloat(comprobante['@_Total']), diff --git a/src/services/database.js b/src/services/database.js index a91cfe6..3c68cf7 100644 --- a/src/services/database.js +++ b/src/services/database.js @@ -12,6 +12,8 @@ function createDatabase(dbPath) { uuid TEXT UNIQUE NOT NULL, rfc_emisor TEXT, nombre_emisor TEXT, + rfc_receptor TEXT, + nombre_receptor TEXT, fecha TEXT, subtotal REAL, total REAL, diff --git a/src/services/import-service.js b/src/services/import-service.js index a1593f5..9f53bcb 100644 --- a/src/services/import-service.js +++ b/src/services/import-service.js @@ -8,14 +8,19 @@ function importXmlFile(db, filePath) { return { success: false, reason: 'parse_error', error: err.message }; } + // Ignorar complementos de pago (total 0, concepto "Pago") + if (parsed.total === 0) { + return { success: false, reason: 'pago', uuid: parsed.uuid }; + } + const existing = db.prepare('SELECT id FROM facturas WHERE uuid = ?').get(parsed.uuid); if (existing) { return { success: false, reason: 'duplicate', uuid: parsed.uuid }; } const insertFactura = db.prepare(` - INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, fecha, subtotal, total, xml_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertConcepto = db.prepare(` @@ -32,6 +37,8 @@ function importXmlFile(db, filePath) { parsed.uuid, parsed.rfcEmisor, parsed.nombreEmisor, + parsed.rfcReceptor, + parsed.nombreReceptor, parsed.fecha, parsed.subtotal, parsed.total, diff --git a/src/services/sftp-sync.js b/src/services/sftp-sync.js new file mode 100644 index 0000000..baf358f --- /dev/null +++ b/src/services/sftp-sync.js @@ -0,0 +1,105 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SFTP_CONFIG = { + host: '172.24.59.88', + port: 22, + username: 'root', + password: process.env.SFTP_PASSWORD || '', + readyTimeout: 10000, +}; + +const REMOTE_BASE = '/var/horux/xml/ROEM691011EZ4/'; + +function getRemoteDir() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + return `${REMOTE_BASE}${year}/${month}/`; +} + +function syncXmls(localDir, onComplete) { + const conn = new Client(); + const remoteDir = getRemoteDir(); + + conn.on('ready', () => { + conn.sftp((err, sftp) => { + if (err) { + console.error('[SFTP] Error al iniciar SFTP:', err.message); + conn.end(); + if (onComplete) onComplete(err); + return; + } + + sftp.readdir(remoteDir, (err, list) => { + if (err) { + console.error('[SFTP] Error al leer directorio remoto:', err.message); + conn.end(); + if (onComplete) onComplete(err); + return; + } + + const xmlFiles = list.filter(f => f.filename.toLowerCase().endsWith('.xml')); + const existingFiles = new Set(fs.readdirSync(localDir).map(f => f.toLowerCase())); + const newFiles = xmlFiles.filter(f => !existingFiles.has(f.filename.toLowerCase())); + + if (newFiles.length === 0) { + conn.end(); + if (onComplete) onComplete(null, []); + return; + } + + console.log(`[SFTP] ${newFiles.length} archivo(s) nuevo(s) en ${remoteDir}`); + let downloaded = 0; + let downloadedFiles = []; + + newFiles.forEach(file => { + const remotePath = remoteDir + file.filename; + const localPath = path.join(localDir, file.filename); + + sftp.fastGet(remotePath, localPath, (err) => { + downloaded++; + if (err) { + console.error(`[SFTP] Error descargando ${file.filename}:`, err.message); + } else { + console.log(`[SFTP] Descargado: ${file.filename}`); + downloadedFiles.push(file.filename); + } + + if (downloaded === newFiles.length) { + console.log(`[SFTP] Sincronización completa. ${downloadedFiles.length} archivo(s) nuevo(s)`); + conn.end(); + if (onComplete) onComplete(null, downloadedFiles); + } + }); + }); + }); + }); + }); + + conn.on('error', (err) => { + console.error('[SFTP] Error de conexión:', err.message); + if (onComplete) onComplete(err); + }); + + conn.connect(SFTP_CONFIG); +} + +function startSyncInterval(localDir, intervalMinutes = 5) { + console.log(`[SFTP] Sincronización automática cada ${intervalMinutes} minuto(s)`); + console.log(`[SFTP] Remoto: ${SFTP_CONFIG.host}:${REMOTE_BASE}//`); + console.log(`[SFTP] Local: ${localDir}`); + + // Ejecutar inmediatamente + syncXmls(localDir); + + // Revisar cada 5 minutos + const intervalId = setInterval(() => { + syncXmls(localDir); + }, intervalMinutes * 60 * 1000); + + return intervalId; +} + +module.exports = { syncXmls, startSyncInterval, SFTP_CONFIG }; diff --git a/src/services/zpl-generator.js b/src/services/zpl-generator.js index fa579b4..491200d 100644 --- a/src/services/zpl-generator.js +++ b/src/services/zpl-generator.js @@ -1,19 +1,41 @@ -function generateLabel(article, quantity = 1) { +const DEFAULTS = { + label_width: 2, + label_height: 1, + label_desc_x: 30, + label_desc_y: 20, + label_desc_font: 28, + label_inter_x: 30, + label_inter_y: 60, + label_inter_font: 22, + label_barcode_x: 30, + label_barcode_y: 95, + label_barcode_height: 50, + label_barcode_show: 1, +}; + +function generateLabel(article, quantity = 1, style = {}) { + const s = Object.assign({}, DEFAULTS, style); + const desc = (article.descripcion || '').substring(0, 40); const intercambio = article.intercambio || 'S/N'; const code = article.noIdentificacion || ''; - const singleLabel = [ + const lines = [ '^XA', - `^FO30,20^A0N,28,28^FD${desc}^FS`, - `^FO30,60^A0N,22,22^FDInt: ${intercambio}^FS`, - `^FO30,95^BY2^BCN,50,Y,N,N^FD${code}^FS`, - '^XZ', - ].join('\n'); + `^FO${s.label_desc_x},${s.label_desc_y}^A0N,${s.label_desc_font},${s.label_desc_font}^FD${desc}^FS`, + `^FO${s.label_inter_x},${s.label_inter_y}^A0N,${s.label_inter_font},${s.label_inter_font}^FDInt: ${intercambio}^FS`, + ]; + + if (Number(s.label_barcode_show)) { + lines.push(`^FO${s.label_barcode_x},${s.label_barcode_y}^BY2^BCN,${s.label_barcode_height},Y,N,N^FD${code}^FS`); + } + + lines.push('^XZ'); + + const singleLabel = lines.join('\n'); if (quantity <= 1) return singleLabel; - return Array(quantity).fill(singleLabel).join('\n'); } -module.exports = { generateLabel }; +module.exports = { generateLabel, DEFAULTS };