descargar pdf y fotos

This commit is contained in:
2026-06-09 08:25:57 -05:00
parent 0a9c672c75
commit b19deda02c
4404 changed files with 664668 additions and 84 deletions
+406
View File
@@ -12,6 +12,8 @@ const crypto = require('crypto');
const multer = require('multer');
const sharp = require('sharp');
const XLSX = require('xlsx');
const PDFDocument = require('pdfkit');
const archiver = require('archiver');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -375,6 +377,24 @@ async function requireProductosManagement(req, res, next) {
next();
}
// Permite acceder si es admin O tiene el permiso 'descargas'
async function requireDescargasAccess(req, res, next) {
const session = await getSessionUser(req);
if (!session) return res.status(401).json({ error: 'No autorizado' });
const user = await getUserData(session.usuario);
if (!user) return res.status(401).json({ error: 'No autorizado' });
const esAdmin = user.rol === 'admin';
const tienePermiso = (user.permisos || []).includes('descargas');
if (!esAdmin && !tienePermiso) {
return res.status(403).json({ error: 'No tienes permiso para acceder a Descargas' });
}
req.user = user;
next();
}
// ════════════════════════════════
// RUTAS DE AUTENTICACIÓN
// ════════════════════════════════
@@ -1480,6 +1500,392 @@ app.post('/api/admin/cleanup-sessions', requireAdmin, async (req, res) => {
res.json({ ok: true, ...result });
});
// ════════════════════════════════
// GENERACIÓN DE PDF DEL CATÁLOGO (admin o permiso 'descargas')
//
// Body: {
// seleccion: [{ cat: 'bolsos', sub: ['dama', 'aaa'] }, ...]
// }
// - cat: id de la categoría raíz
// - sub: array opcional con la cadena de subcategorías (puede ser [] para "toda la categoría")
//
// Layout del PDF (A4 vertical):
// 1. Portada con logo, título "Catálogo" y fecha
// 2. Una página (o más) por cada hoja seleccionada, con:
// - Subtítulo grande con la ruta completa (Cat > Sub1 > Sub2)
// - Grid 2xN de productos: foto + ref + nombre + precio
// 3. Footer con número de página en todas las hojas
// ════════════════════════════════
app.post('/api/descargas/pdf', requireDescargasAccess, async (req, res) => {
const { seleccion } = req.body || {};
if (!Array.isArray(seleccion) || seleccion.length === 0) {
return res.status(400).json({ error: 'Debes seleccionar al menos una categoría o subcategoría' });
}
try {
const categorias = await getCategorias();
const productos = await getProductos();
// Validar que cada item de selección existe en el árbol
for (const item of seleccion) {
if (!item || typeof item.cat !== 'string') {
return res.status(400).json({ error: 'Selección inválida' });
}
const cat = categorias.find(c => c.id === item.cat);
if (!cat) {
return res.status(400).json({ error: `Categoría "${item.cat}" no existe` });
}
if (Array.isArray(item.sub) && item.sub.length > 0) {
let nodo = cat;
for (let i = 0; i < item.sub.length; i++) {
const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : [];
const sig = subs.find(s => s.id === item.sub[i]);
if (!sig) {
return res.status(400).json({ error: `Subcategoría "${item.sub[i]}" no encontrada` });
}
nodo = sig;
}
}
}
// Filtrar productos que coincidan con alguna selección.
// - Excluye sin-categoria y agotados
// - Excluye productos sin imagen
const productosFiltrados = productos.filter(p => {
if (!p.cat || p.cat === SIN_CATEGORIA_ID) return false;
if (!p.img) return false;
if (p.img.startsWith('agotados/')) return false;
return seleccion.some(sel => {
if (p.cat !== sel.cat) return false;
const pSub = Array.isArray(p.sub) ? p.sub : [];
const selSub = Array.isArray(sel.sub) ? sel.sub : [];
// Selección "toda la categoría" (sub vacío) matchea cualquier sub del producto
if (selSub.length === 0) return true;
if (pSub.length < selSub.length) return false;
for (let i = 0; i < selSub.length; i++) {
if (pSub[i] !== selSub[i]) return false;
}
return true;
});
});
if (productosFiltrados.length === 0) {
return res.status(400).json({ error: 'No hay productos disponibles en las hojas seleccionadas' });
}
// Ordenar productos por sección (cat > sub) y luego por referencia
productosFiltrados.sort((a, b) => {
if (a.cat !== b.cat) return a.cat.localeCompare(b.cat);
const aSub = (a.sub || []).join('');
const bSub = (b.sub || []).join('');
if (aSub !== bSub) return aSub.localeCompare(bSub);
return String(a.ref).localeCompare(String(b.ref));
});
// === Generar el PDF ===
const filename = `catalogo-infinity-${Date.now()}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = new PDFDocument({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
info: { Title: 'Catálogo Infinity', Author: 'Infinity', Subject: 'Catálogo de productos' }
});
doc.pipe(res);
const pageW = doc.page.width;
const pageH = doc.page.height;
const MARGIN_X = 50;
// Constantes de layout de productos
const CARD_W = 240;
const CARD_H = 290;
const IMG_H = 180;
const GUTTER = 20;
const TOP_Y = 110; // bajo el subtítulo
const BOTTOM_Y = pageH - 60; // deja espacio para el footer
// Helper: nombre legible de la sección (cat > sub1 > sub2)
function getSeccionNombre(catObj, subArr) {
const parts = [catObj.nombre];
let nodo = catObj;
for (const sid of subArr) {
const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : [];
const s = subs.find(x => x.id === sid);
if (!s) break;
parts.push(s.nombre);
nodo = s;
}
return parts.join(' ');
}
// Helper: dibuja el header de una sección (subtítulo + línea)
function drawSeccionHeader(titulo) {
doc.fontSize(22).fillColor('#8b5cf6').font('Helvetica-Bold')
.text(titulo, MARGIN_X, 50, { width: pageW - 2 * MARGIN_X });
doc.moveTo(MARGIN_X, 90).lineTo(pageW - MARGIN_X, 90)
.strokeColor('rgba(139, 92, 246, 0.5)').lineWidth(1.5).stroke();
}
// Helper: dibuja el placeholder si una imagen no se pudo cargar
function drawImagePlaceholder(x, y) {
doc.rect(x, y, CARD_W, IMG_H).fillColor('#f5f3ff').fill();
doc.fontSize(11).fillColor('#8b8ba7').font('Helvetica')
.text('Sin imagen', x, y + IMG_H / 2 - 6, { width: CARD_W, align: 'center' });
}
// Helper: convierte una imagen del catálogo (webp) a un buffer JPG
async function loadImageBuffer(imgRelPath) {
const imgPath = path.join(IMG_DIR, imgRelPath);
const buffer = await fs.readFile(imgPath);
return await sharp(buffer).jpeg({ quality: 85 }).toBuffer();
}
// Helper: dibuja el footer (número de página)
function drawPageFooter() {
const range = doc.bufferedPageRange();
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
doc.fontSize(8).fillColor('#8b8ba7').font('Helvetica')
.text(`${i + 1} / ${range.count}`,
MARGIN_X, pageH - 40, { width: pageW - 2 * MARGIN_X, align: 'center' });
}
}
// === PORTADA ===
doc.fontSize(80).fillColor('#8b5cf6').font('Helvetica-Bold')
.text('∞', 0, 180, { align: 'center', width: pageW });
doc.fontSize(28).fillColor('#1a1a2e').font('Helvetica-Bold')
.text('Infinity', 0, 290, { align: 'center', width: pageW });
doc.moveTo(200, 340).lineTo(pageW - 200, 340)
.strokeColor('#8b5cf6').lineWidth(2).stroke();
doc.fontSize(40).fillColor('#1a1a2e').font('Helvetica-Bold')
.text('Catálogo', 0, 380, { align: 'center', width: pageW });
const fechaStr = new Date().toLocaleDateString('es-CO', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
doc.fontSize(13).fillColor('#4a4a68').font('Helvetica')
.text(fechaStr, 0, 450, { align: 'center', width: pageW });
doc.fontSize(12).fillColor('#8b8ba7').font('Helvetica')
.text(
`${productosFiltrados.length} producto${productosFiltrados.length === 1 ? '' : 's'}`,
0, 510, { align: 'center', width: pageW }
);
doc.fontSize(10).fillColor('#8b8ba7').font('Helvetica')
.text('Generado el ' + new Date().toLocaleString('es-CO'),
0, pageH - 80, { align: 'center', width: pageW });
// === SECCIONES DE PRODUCTOS ===
// Agrupar por sección
const seccionesMap = new Map();
for (const p of productosFiltrados) {
const key = `${p.cat}|${(p.sub || []).join('|')}`;
if (!seccionesMap.has(key)) {
seccionesMap.set(key, { cat: p.cat, sub: p.sub || [], productos: [] });
}
seccionesMap.get(key).productos.push(p);
}
const secciones = Array.from(seccionesMap.values()).sort((a, b) => {
if (a.cat !== b.cat) return a.cat.localeCompare(b.cat);
return a.sub.join('|').localeCompare(b.sub.join('|'));
});
for (const sec of secciones) {
const catObj = categorias.find(c => c.id === sec.cat);
if (!catObj) continue;
const titulo = getSeccionNombre(catObj, sec.sub);
// Nueva página para la sección
doc.addPage();
drawSeccionHeader(titulo);
// Pre-cargar imágenes en paralelo
const productosConBuffer = await Promise.all(
sec.productos.map(async p => {
try {
return { prod: p, imgBuffer: await loadImageBuffer(p.img) };
} catch (err) {
console.error('Error cargando imagen para PDF:', p.img, err.message);
return { prod: p, imgBuffer: null };
}
})
);
let col = 0;
let y = TOP_Y;
for (const { prod, imgBuffer } of productosConBuffer) {
const x = MARGIN_X + col * (CARD_W + GUTTER);
// Salto de página si no cabe (y repetimos subtítulo)
if (y + CARD_H > BOTTOM_Y) {
doc.addPage();
drawSeccionHeader(titulo);
y = TOP_Y;
col = 0;
}
// Foto
if (imgBuffer) {
try {
doc.image(imgBuffer, x, y, { fit: [CARD_W, IMG_H], align: 'center', valign: 'center' });
} catch {
drawImagePlaceholder(x, y);
}
} else {
drawImagePlaceholder(x, y);
}
// Referencia
let textY = y + IMG_H + 8;
doc.fontSize(8).fillColor('#8b8ba7').font('Helvetica')
.text(`REF: ${prod.ref || '—'}`, x, textY, { width: CARD_W });
// Nombre
textY += 12;
doc.fontSize(11).fillColor('#1a1a2e').font('Helvetica-Bold')
.text(prod.nombre || prod.ref || 'Sin nombre', x, textY, { width: CARD_W, height: 50 });
// Precio
textY += 52;
const precio = (prod.precio !== undefined && prod.precio !== null && prod.precio !== '')
? `$${Number(prod.precio).toLocaleString('es-CO')}`
: '—';
doc.fontSize(15).fillColor('#8b5cf6').font('Helvetica-Bold')
.text(precio, x, textY, { width: CARD_W });
col++;
if (col >= 2) {
col = 0;
y += CARD_H + 20;
}
}
}
// === FOOTER con número de página ===
drawPageFooter();
doc.end();
} catch (err) {
console.error('PDF generation error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Error al generar el PDF: ' + err.message });
}
}
});
// ════════════════════════════════
// DESCARGAS — FOTOS (ZIP)
// ════════════════════════════════
app.post('/api/descargas/fotos', requireDescargasAccess, async (req, res) => {
const { seleccion } = req.body || {};
if (!Array.isArray(seleccion) || seleccion.length === 0) {
return res.status(400).json({ error: 'Debes seleccionar al menos una categoría o subcategoría' });
}
try {
const categorias = await getCategorias();
const productos = await getProductos();
// Validar selección (misma lógica que PDF)
for (const item of seleccion) {
if (!item || typeof item.cat !== 'string') {
return res.status(400).json({ error: 'Selección inválida' });
}
const cat = categorias.find(c => c.id === item.cat);
if (!cat) {
return res.status(400).json({ error: `Categoría "${item.cat}" no existe` });
}
if (Array.isArray(item.sub) && item.sub.length > 0) {
let nodo = cat;
for (let i = 0; i < item.sub.length; i++) {
const subs = Array.isArray(nodo.subcategorias) ? nodo.subcategorias : [];
const sig = subs.find(s => s.id === item.sub[i]);
if (!sig) {
return res.status(400).json({ error: `Subcategoría "${item.sub[i]}" no encontrada` });
}
nodo = sig;
}
}
}
// Filtrar productos (misma lógica que PDF)
const productosFiltrados = productos.filter(p => {
if (!p.cat || p.cat === SIN_CATEGORIA_ID) return false;
if (!p.img) return false;
if (p.img.startsWith('agotados/')) return false;
return seleccion.some(sel => {
if (p.cat !== sel.cat) return false;
const pSub = Array.isArray(p.sub) ? p.sub : [];
const selSub = Array.isArray(sel.sub) ? sel.sub : [];
if (selSub.length === 0) return true;
if (pSub.length < selSub.length) return false;
for (let i = 0; i < selSub.length; i++) {
if (pSub[i] !== selSub[i]) return false;
}
return true;
});
});
if (productosFiltrados.length === 0) {
return res.status(400).json({ error: 'No hay productos disponibles en las hojas seleccionadas' });
}
// Stream ZIP
const filename = `fotos-infinity-${Date.now()}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', err => {
console.error('ZIP error:', err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Error al generar el ZIP: ' + err.message });
} else {
res.end();
}
});
archive.pipe(res);
for (const p of productosFiltrados) {
const imgRelPath = p.img;
const imgAbsPath = path.join(IMG_DIR, imgRelPath);
const ext = path.extname(imgRelPath) || '.webp';
const arcName = `${p.ref || 'producto'}${ext}`;
try {
await fs.access(imgAbsPath);
archive.file(imgAbsPath, { name: arcName });
} catch {
console.warn(`[fotos] Imagen no encontrada: ${imgAbsPath}`);
}
}
await archive.finalize();
} catch (err) {
console.error('Fotos ZIP generation error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Error al generar el ZIP: ' + err.message });
}
}
});
// ════════════════════════════════
// START
// ════════════════════════════════