Add SFTP sync, receptor filter, payment exclusion, label editor, and catalog delete

- Add SFTP auto-sync every 5 minutes from remote server (ssh2)
- Parse and store receptor (RFC/name) from CFDI XMLs
- Filter all views to show only invoices for receptor ROEM691011EZ4
- Exclude payment complements (total $0) from import
- Add label style editor with live canvas preview (size, fonts, positions, barcode)
- Parametrize ZPL generator to use saved label style config
- Add delete button for catalog intercambios
- Add manual sync endpoint POST /api/sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Consultoria-as
2026-05-03 01:39:49 -07:00
parent f88d5eb6b6
commit 2187c046e2
16 changed files with 653 additions and 28 deletions

74
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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}`);
});

View File

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

View File

@@ -28,6 +28,10 @@
<span class="nav-icon">&#128203;</span>
<span class="nav-text">Cat&aacute;logo</span>
</a>
<a href="#etiqueta-editor" class="nav-link" data-route="etiqueta-editor">
<span class="nav-icon">&#127991;&#65039;</span>
<span class="nav-text">Estilo Etiqueta</span>
</a>
<a href="#config" class="nav-link" data-route="config">
<span class="nav-icon">&#9881;&#65039;</span>
<span class="nav-text">Configuraci&oacute;n</span>

View File

@@ -111,6 +111,9 @@
case 'catalogo':
renderCatalogo();
break;
case 'etiqueta-editor':
renderEtiquetaEditor();
break;
case 'config':
renderConfig();
break;
@@ -667,7 +670,8 @@
'<td class="cat-col-proveedor">' + escapeHtml(item.no_parte_proveedor) + '</td>' +
'<td class="cat-col-intercambio">' + escapeHtml(item.no_parte_intercambio) + '</td>' +
'<td>' +
'<button class="btn btn-secondary btn-sm cat-btn-edit">Editar</button>' +
'<button class="btn btn-secondary btn-sm cat-btn-edit">Editar</button> ' +
'<button class="btn btn-danger btn-sm cat-btn-delete">Eliminar</button>' +
'</td>' +
'</tr>';
}).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 =
'<div class="empty-state">' +
@@ -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 '<div class="form-group">' +
'<label for="' + id + '">' + label + '</label>' +
'<input type="number" id="' + id + '" value="' + value + '" step="1">' +
'</div>';
}
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Estilo de Etiqueta</h2>' +
'<p>Personaliza el dise\u00f1o de las etiquetas y previsualiza antes de imprimir</p>' +
'</div>' +
'<div class="label-editor-layout">' +
'<div class="card"><div class="card-body label-editor-controls">' +
'<div class="label-editor-section">' +
'<h4>Tama\u00f1o de Etiqueta</h4>' +
'<div class="form-row">' +
numInput('le-width', 'Ancho (pulg)', vals.label_width) +
numInput('le-height', 'Alto (pulg)', vals.label_height) +
'</div>' +
'</div>' +
'<div class="label-editor-section">' +
'<h4>Descripci\u00f3n</h4>' +
'<div class="form-row">' +
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) +
'</div>' +
'</div>' +
'<div class="label-editor-section">' +
'<h4>Intercambio</h4>' +
'<div class="form-row">' +
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) +
'</div>' +
'</div>' +
'<div class="label-editor-section">' +
'<h4>C\u00f3digo de Barras</h4>' +
'<div class="form-row">' +
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) +
'</div>' +
'<div class="checkbox-group" style="margin-top:8px">' +
'<input type="checkbox" id="le-bar-show"' + (vals.label_barcode_show ? ' checked' : '') + '>' +
'<label for="le-bar-show">Mostrar c\u00f3digo de barras</label>' +
'</div>' +
'</div>' +
'<div class="label-editor-actions">' +
'<button class="btn btn-primary" id="le-btn-save">Guardar Estilo</button>' +
'<button class="btn btn-secondary" id="le-btn-reset">Restablecer</button>' +
'</div>' +
'</div></div>' +
'<div class="label-preview-panel">' +
'<div class="card"><div class="card-body">' +
'<h3>Vista Previa</h3>' +
'<div class="label-canvas-wrapper">' +
'<canvas id="le-canvas"></canvas>' +
'</div>' +
'</div></div>' +
'</div>' +
'</div>';
// 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 =
'<div class="page-header"><h2>Estilo de Etiqueta</h2></div>' +
'<div class="empty-state"><div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error: ' + escapeHtml(err.message) + '</p></div>';
}
}
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
// ============================================

View File

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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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']),

View File

@@ -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,

View File

@@ -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,

105
src/services/sftp-sync.js Normal file
View File

@@ -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}<año>/<mes>/`);
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 };

View File

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