Excel y sesiones.json a las 00
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user