feat(cfdi): descarga masiva de XMLs como ZIP, limite 1,000

This commit is contained in:
Horux Dev
2026-05-24 21:19:56 +00:00
parent 80e2c099d9
commit 5c940847af
5 changed files with 131 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js'; import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.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 { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js'; import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.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) { export async function listConceptos(req: Request, res: Response, next: NextFunction) {
try { try {
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado')); if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));

View File

@@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos);
router.get('/:id', cfdiController.getCfdiById); router.get('/:id', cfdiController.getCfdiById);
router.get('/:id/conceptos', cfdiController.getConceptos); router.get('/:id/conceptos', cfdiController.getConceptos);
router.get('/:id/xml', cfdiController.getXml); router.get('/:id/xml', cfdiController.getXml);
router.post('/download-xmls', cfdiController.downloadXmlsZip);
router.post('/', checkCfdiLimit, cfdiController.createCfdi); router.post('/', checkCfdiLimit, cfdiController.createCfdi);
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts // Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis); router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);

View File

@@ -357,6 +357,13 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | null>
return rows[0]?.xml_original || null; 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 { export interface CreateCfdiData {
uuid: string; uuid: string;
type: 'EMITIDO' | 'RECIBIDO'; type: 'EMITIDO' | 'RECIBIDO';

View File

@@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header'; 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 { 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 { 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 { cancelarFactura, downloadPdf } from '@/lib/api/facturacion';
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi'; import type { CreateCfdiData } from '@/lib/api/cfdi';
@@ -261,6 +261,8 @@ export default function CfdiPage() {
const [loadingEmisor, setLoadingEmisor] = useState(false); const [loadingEmisor, setLoadingEmisor] = useState(false);
const [loadingReceptor, setLoadingReceptor] = useState(false); const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [downloadingXmls, setDownloadingXmls] = useState(false);
// Debounced values for autocomplete // Debounced values for autocomplete
const debouncedEmisor = useDebounce(columnFilters.emisor, 300); const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
@@ -1760,6 +1762,54 @@ export default function CfdiPage() {
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
CFDIs ({data?.total || 0}) CFDIs ({data?.total || 0})
</CardTitle> </CardTitle>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<>
<span className="text-xs text-muted-foreground">
{selectedIds.size} seleccionados
</span>
<Button
variant="outline"
size="sm"
onClick={async () => {
if (selectedIds.size > 1000) {
alert('Máximo 1,000 CFDIs por descarga');
return;
}
try {
setDownloadingXmls(true);
const blob = await downloadXmlsZip(Array.from(selectedIds));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdis-xml-${Date.now()}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al descargar XMLs');
} finally {
setDownloadingXmls(false);
}
}}
disabled={downloadingXmls}
>
{downloadingXmls ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Descargar XMLs
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
Limpiar
</Button>
</>
)}
</div>
{hasActiveColumnFilters && ( {hasActiveColumnFilters && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Filtros activos:</span> <span>Filtros activos:</span>
@@ -1817,6 +1867,19 @@ export default function CfdiPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b text-center text-sm text-muted-foreground"> <tr className="border-b text-center text-sm text-muted-foreground">
<th className="pb-3 w-8">
<input
type="checkbox"
checked={data?.data.length ? selectedIds.size === data.data.length : false}
onChange={() => {
if (selectedIds.size === data?.data.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(data?.data.map((c: any) => c.id) || []));
}
}}
/>
</th>
<th className="pb-3 font-medium"> <th className="pb-3 font-medium">
<div className="flex items-center gap-1 justify-center"> <div className="flex items-center gap-1 justify-center">
Fecha Fecha
@@ -1992,6 +2055,20 @@ export default function CfdiPage() {
<tbody className="text-sm text-center"> <tbody className="text-sm text-center">
{data?.data.map((cfdi) => ( {data?.data.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50"> <tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3">
<input
type="checkbox"
checked={selectedIds.has(cfdi.id)}
onChange={() => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(cfdi.id)) next.delete(cfdi.id);
else next.add(cfdi.id);
return next;
});
}}
/>
</td>
<td className="py-3">{formatDate(cfdi.fechaEmision)}</td> <td className="py-3">{formatDate(cfdi.fechaEmision)}</td>
<td className="py-3"> <td className="py-3">
<span className="text-xs" title={formatTipoComprobante(cfdi.tipoComprobante)}> <span className="text-xs" title={formatTipoComprobante(cfdi.tipoComprobante)}>

View File

@@ -91,6 +91,11 @@ export async function getCfdiById(id: string): Promise<Cfdi> {
return response.data; return response.data;
} }
export async function downloadXmlsZip(ids: number[]): Promise<Blob> {
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) { export async function getResumenCfdi(año?: number, mes?: number, contribuyenteId?: string) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (año) params.set('año', año.toString()); if (año) params.set('año', año.toString());