descargar pdf y fotos
This commit is contained in:
@@ -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
|
||||
// ════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user