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:
74
package-lock.json
generated
74
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
22
src/index.js
22
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}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<span class="nav-icon">📋</span>
|
||||
<span class="nav-text">Catálogo</span>
|
||||
</a>
|
||||
<a href="#etiqueta-editor" class="nav-link" data-route="etiqueta-editor">
|
||||
<span class="nav-icon">🏷️</span>
|
||||
<span class="nav-text">Estilo Etiqueta</span>
|
||||
</a>
|
||||
<a href="#config" class="nav-link" data-route="config">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-text">Configuración</span>
|
||||
|
||||
@@ -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">⚠️</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
|
||||
// ============================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
105
src/services/sftp-sync.js
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user