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 };