Excel y sesiones.json a las 00

This commit is contained in:
2026-06-05 22:56:35 -05:00
parent aa5e85ae94
commit 984f2f103d
7 changed files with 550 additions and 107 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.5"
"sharp": "^0.33.5",
"xlsx": "^0.18.5"
},
"engines": {
"node": ">=18"
+280
View File
@@ -11,6 +11,7 @@ const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const sharp = require('sharp');
const XLSX = require('xlsx');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -904,6 +905,219 @@ app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) =
}
});
// ════════════════════════════════
// ASIGNAR NOMBRE + PRECIO DESDE EXCEL/CSV (admin o permiso 'productos')
//
// El archivo debe tener encabezados en la primera fila:
// A: REFERENCIA | B: DESCRIPCION | C: PRECIO
//
// Reglas:
// - El Excel/CSV NO crea productos nuevos. Solo actualiza los que ya
// existen en productos.json (match por `ref` exacto).
// - Si una celda está vacía, NO sobrescribe el valor actual.
// - Precio acepta: "50000", "$ 50.000", "50,000.00", "50.000,50", etc.
// - Si un `ref` del archivo no existe, se reporta en "noEncontrados".
// ════════════════════════════════
// Helper: normalizar texto para comparar headers (trim, lowercase, sin acentos)
function normHeader(s) {
return String(s || '')
.trim()
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
// Helper: parsear precio en múltiples formatos (CO/US/EU)
// "50000" → 50000
// "$ 50.000" → 50000 (formato CO: punto = miles)
// "50,000.00" → 50000 (formato US: coma = miles, punto = decimal)
// "50.000,50" → 50000.5 (formato CO/EU: punto = miles, coma = decimal)
// " $1.234,56" → 1234.56
// "75.500" → 75500 (heurística: 3 dígitos tras último punto = miles)
// "75.5" → 75.5 (1-2 dígitos tras último punto = decimal)
function parsePrice(value) {
if (value === null || value === undefined || value === '') return null;
let s = String(value).trim();
if (!s) return null;
// Quitar símbolos de moneda y espacios
s = s.replace(/[$€£¥₹\s]/g, '');
if (!s) return null;
const isNegative = (s.startsWith('-') || (s.startsWith('(') && s.endsWith(')')));
s = s.replace(/^[-()]/, '').replace(/\)$/, '');
const lastComma = s.lastIndexOf(',');
const lastDot = s.lastIndexOf('.');
let normalized;
if (lastComma !== -1 && lastDot !== -1) {
// Hay ambos separadores: el ÚLTIMO es el decimal
if (lastComma > lastDot) {
// Formato CO/EU: "1.000,50" → coma es decimal
normalized = s.replace(/\./g, '').replace(',', '.');
} else {
// Formato US: "1,000.50" → punto es decimal
normalized = s.replace(/,/g, '');
}
} else if (lastComma !== -1) {
// Solo comas. Si la última parte tiene 1-2 dígitos, decimal; si no, miles
const parts = s.split(',');
if (parts.length === 2 && parts[1].length <= 2) {
normalized = s.replace(',', '.');
} else {
normalized = s.replace(/,/g, '');
}
} else if (lastDot !== -1) {
// Solo puntos. Heurística: si la parte tras el último punto tiene 3 dígitos
// (y no hay otro punto), es separador de miles (formato CO: "75.000" = 75000).
// Si tiene 1-2 dígitos, es decimal ("75.5" = 75.5).
const afterLastDot = s.slice(lastDot + 1);
if (s.split('.').length === 2 && afterLastDot.length === 3 && /^\d+$/.test(afterLastDot)) {
// Formato CO sin comas: "75.000" = 75000
normalized = s.replace(/\./g, '');
} else {
// Decimal: "75.5", "75.50", "1.5.5" (raro pero lo dejamos pasar)
normalized = s;
}
} else {
normalized = s;
}
const num = parseFloat(normalized);
if (isNaN(num)) return null;
return isNegative ? -num : num;
}
// Multer específico para el Excel (1 solo archivo, hasta 5MB)
const excelUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 }
});
app.post('/api/productos/excel', requireProductosManagement, excelUpload.single('archivo'), async (req, res) => {
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'No se envió ningún archivo' });
}
try {
// Parsear el archivo (.xlsx o .csv). SheetJS detecta el formato por contenido.
// codepage: 65001 fuerza UTF-8 (necesario para acentos/ñ en CSV).
const wb = XLSX.read(file.buffer, { type: 'buffer', codepage: 65001 });
const sheetName = wb.SheetNames[0];
if (!sheetName) {
return res.status(400).json({ error: 'El archivo no tiene hojas' });
}
const sheet = wb.Sheets[sheetName];
// header: 1 → array de arrays. raw: false devuelve el valor FORMATEADO de la celda
// (preserva strings tal cual aparecen, ej: "45.000,50" se queda como string).
// Esto es crítico para no interpretar mal separadores de miles/decimales.
// defval: '' para celdas vacías.
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false });
if (rows.length === 0) {
return res.status(400).json({ error: 'El archivo está vacío' });
}
// Validar encabezados (case-insensitive, sin acentos)
const headersRaw = rows[0].map(h => normHeader(h));
const expected = ['referencia', 'descripcion', 'precio'];
if (
headersRaw.length < 3 ||
headersRaw[0] !== expected[0] ||
headersRaw[1] !== expected[1] ||
headersRaw[2] !== expected[2]
) {
return res.status(400).json({
error: `Los encabezados deben ser exactamente: ${expected.join(' | ').toUpperCase()}. Encontrado: ${rows[0].map(h => String(h).trim() || '(vacío)').join(' | ').toUpperCase()}`
});
}
// Procesar filas
const productos = await getProductos();
const productosByRef = new Map(productos.map(p => [String(p.ref), p]));
const actualizados = [];
const noEncontrados = [];
const errores = [];
const filasVacias = [];
let filasConDatos = 0;
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
// Saltar filas completamente vacías
const isEmpty = row.every(c => c === '' || c === null || c === undefined);
if (isEmpty) {
filasVacias.push(i + 1); // 1-indexed para el usuario
continue;
}
const refRaw = row[0] != null ? String(row[0]).trim() : '';
const descRaw = row[1] != null ? String(row[1]).trim() : '';
const precioRaw = row[2];
if (!refRaw) {
errores.push({ fila: i + 1, motivo: 'REFERENCIA vacía' });
continue;
}
const producto = productosByRef.get(refRaw);
if (!producto) {
noEncontrados.push(refRaw);
continue;
}
// Actualizar SOLO si la celda tiene valor
let changed = false;
if (descRaw) {
producto.nombre = descRaw;
changed = true;
}
if (precioRaw !== '' && precioRaw !== null && precioRaw !== undefined) {
// XLSX puede entregar números o strings según formato de celda
let precio;
if (typeof precioRaw === 'number') {
precio = precioRaw;
} else {
precio = parsePrice(precioRaw);
}
if (precio === null || isNaN(precio)) {
errores.push({ fila: i + 1, ref: refRaw, motivo: `Precio inválido: "${precioRaw}"` });
continue;
}
producto.precio = precio;
changed = true;
}
if (changed) {
actualizados.push({ ref: refRaw, fila: i + 1 });
filasConDatos++;
}
}
await saveProductos(productos);
res.json({
ok: true,
resumen: {
actualizados: actualizados.length,
actualizadosList: actualizados,
noEncontrados: noEncontrados.length,
noEncontradosList: noEncontrados,
errores: errores.length,
erroresList: errores,
filasVacias: filasVacias.length,
filasVaciasList: filasVacias
}
});
} catch (err) {
console.error('Excel upload error:', err);
res.status(500).json({ error: 'Error al procesar el archivo: ' + err.message });
}
});
// Multer error handler (tamaño excedido, etc.)
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
@@ -931,9 +1145,75 @@ app.get('/administrativo/', (req, res) => {
res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html'));
});
// ════════════════════════════════
// LIMPIEZA DE SESIONES A LAS 00:00 (America/Bogota)
// Vacía sesiones.json todos los días a medianoche hora Colombia.
// Cuando los usuarios abran la app después de las 00:00, tendrán
// que volver a loguearse (sus cookies quedaron firmadas pero el
// token ya no existe en sesiones.json → 401 → redirige a /login).
// ════════════════════════════════
function msUntilNextMidnightBogota() {
// Obtener la hora actual en America/Bogota (UTC-5, sin DST)
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Bogota',
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const parts = {};
for (const part of formatter.formatToParts(new Date())) {
if (part.type !== 'literal') parts[part.type] = part.value;
}
// En formato 24h, medianoche puede aparecer como "24" en algunos sistemas
const hour = parseInt(parts.hour === '24' ? '0' : parts.hour, 10);
const minute = parseInt(parts.minute, 10);
const second = parseInt(parts.second, 10);
const msPassedToday = (hour * 3600 + minute * 60 + second) * 1000;
return 24 * 3600 * 1000 - msPassedToday;
}
async function runSessionCleanup(logPrefix = '[cleanup]') {
try {
const sesiones = await getSesiones();
if (sesiones.length === 0) {
console.log(`${logPrefix} sesiones.json ya está vacío, nada que limpiar`);
return { removed: 0 };
}
await saveSesiones([]);
console.log(`${logPrefix}${sesiones.length} sesión(es) cerrada(s) a las 00:00 CO`);
return { removed: sesiones.length };
} catch (err) {
console.error(`${logPrefix} Error limpiando sesiones:`, err.message);
return { removed: 0, error: err.message };
}
}
function scheduleSessionCleanup() {
const ms = msUntilNextMidnightBogota();
const hours = (ms / 1000 / 60 / 60).toFixed(2);
console.log(`[cleanup] Próxima limpieza de sesiones en ${hours}h (00:00 America/Bogota)`);
setTimeout(() => {
runSessionCleanup();
// Después de la primera ejecución, repetir cada 24h
setInterval(() => runSessionCleanup(), 24 * 60 * 60 * 1000);
}, ms);
}
// ════════════════════════════════
// ENDPOINT MANUAL (admin) — útil para emergencias o testing
// Cierra TODAS las sesiones inmediatamente.
// ════════════════════════════════
app.post('/api/admin/cleanup-sessions', requireAdmin, async (req, res) => {
const result = await runSessionCleanup('[manual]');
res.json({ ok: true, ...result });
});
// ════════════════════════════════
// START
// ════════════════════════════════
app.listen(PORT, () => {
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
scheduleSessionCleanup();
});