diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index 343ca85..0bb3aeb 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from 'express'; import * as cfdiService from '../services/cfdi.service.js'; import { AppError } from '../middlewares/error.middleware.js'; +import AdmZip from 'adm-zip'; import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js'; import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js'; import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; @@ -75,6 +76,45 @@ export async function getXml(req: Request, res: Response, next: NextFunction) { } } +export async function downloadXmlsZip(req: Request, res: Response, next: NextFunction) { + try { + if (!req.tenantPool) { + return next(new AppError(400, 'Tenant no configurado')); + } + + const ids = req.body.ids as number[]; + if (!Array.isArray(ids) || ids.length === 0) { + return next(new AppError(400, 'Se requiere un array de IDs')); + } + if (ids.length > 1000) { + return next(new AppError(400, 'Máximo 1,000 CFDIs por descarga')); + } + + const cfdis = await cfdiService.getXmlsByIds(req.tenantPool, ids); + const zip = new AdmZip(); + let added = 0; + + for (const cfdi of cfdis) { + if (cfdi.xml) { + const filename = `${cfdi.uuid || cfdi.id}.xml`; + zip.addFile(filename, Buffer.from(cfdi.xml, 'utf8')); + added++; + } + } + + if (added === 0) { + return next(new AppError(404, 'No se encontraron XMLs para los CFDIs seleccionados')); + } + + const zipBuffer = zip.toBuffer(); + res.set('Content-Type', 'application/zip'); + res.set('Content-Disposition', `attachment; filename="cfdis-${Date.now()}.zip"`); + res.send(zipBuffer); + } catch (error) { + next(error); + } +} + export async function listConceptos(req: Request, res: Response, next: NextFunction) { try { if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado')); diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts index 75ccd62..c72a613 100644 --- a/apps/api/src/routes/cfdi.routes.ts +++ b/apps/api/src/routes/cfdi.routes.ts @@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos); router.get('/:id', cfdiController.getCfdiById); router.get('/:id/conceptos', cfdiController.getConceptos); router.get('/:id/xml', cfdiController.getXml); +router.post('/download-xmls', cfdiController.downloadXmlsZip); router.post('/', checkCfdiLimit, cfdiController.createCfdi); // Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis); diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts index 525ed21..c7057b9 100644 --- a/apps/api/src/services/cfdi.service.ts +++ b/apps/api/src/services/cfdi.service.ts @@ -357,6 +357,13 @@ export async function getXmlById(pool: Pool, id: string): Promise return rows[0]?.xml_original || null; } +export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> { + const { rows } = await pool.query(` + SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1) + `, [ids]); + return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null })); +} + export interface CreateCfdiData { uuid: string; type: 'EMITIDO' | 'RECIBIDO'; diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 2dd750d..cc741d7 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui'; import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; -import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi'; +import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, downloadXmlsZip, type EmisorReceptor } from '@/lib/api/cfdi'; import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion'; import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; import type { CreateCfdiData } from '@/lib/api/cfdi'; @@ -261,6 +261,8 @@ export default function CfdiPage() { const [loadingEmisor, setLoadingEmisor] = useState(false); const [loadingReceptor, setLoadingReceptor] = useState(false); const [showForm, setShowForm] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [downloadingXmls, setDownloadingXmls] = useState(false); // Debounced values for autocomplete const debouncedEmisor = useDebounce(columnFilters.emisor, 300); @@ -1760,6 +1762,54 @@ export default function CfdiPage() { CFDIs ({data?.total || 0}) +
+ {selectedIds.size > 0 && ( + <> + + {selectedIds.size} seleccionados + + + + + )} +
{hasActiveColumnFilters && (
Filtros activos: @@ -1817,6 +1867,19 @@ export default function CfdiPage() { + {data?.data.map((cfdi) => ( +
+ { + if (selectedIds.size === data?.data.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(data?.data.map((c: any) => c.id) || [])); + } + }} + /> +
Fecha @@ -1992,6 +2055,20 @@ export default function CfdiPage() {
+ { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(cfdi.id)) next.delete(cfdi.id); + else next.add(cfdi.id); + return next; + }); + }} + /> + {formatDate(cfdi.fechaEmision)} diff --git a/apps/web/lib/api/cfdi.ts b/apps/web/lib/api/cfdi.ts index fe276c7..aa6a9c9 100644 --- a/apps/web/lib/api/cfdi.ts +++ b/apps/web/lib/api/cfdi.ts @@ -91,6 +91,11 @@ export async function getCfdiById(id: string): Promise { return response.data; } +export async function downloadXmlsZip(ids: number[]): Promise { + const response = await apiClient.post('/cfdi/download-xmls', { ids }, { responseType: 'blob' }); + return response.data; +} + export async function getResumenCfdi(año?: number, mes?: number, contribuyenteId?: string) { const params = new URLSearchParams(); if (año) params.set('año', año.toString());