refactor(cfdi): descarga masiva de XMLs por filtros en lugar de checkboxes

- Backend: POST /cfdi/download-xmls acepta CfdiFilters, usa getXmlsByFilters con LIMIT 1000
- Frontend: eliminados checkboxes y estado selectedIds; botón Descargar XMLs usa filtros activos
- Si >1000 resultados, muestra confirm() de advertencia pero permite proceder
- Agregada documentación técnica y changelog
This commit is contained in:
Horux Dev
2026-05-24 21:40:08 +00:00
parent 5c940847af
commit e35eae2a72
6 changed files with 415 additions and 87 deletions

View File

@@ -82,28 +82,33 @@ export async function downloadXmlsZip(req: Request, res: Response, next: NextFun
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 filters: CfdiFilters = {
tipo: req.body.tipo as any,
tipoComprobante: req.body.tipoComprobante as any,
estado: req.body.estado as any,
fechaInicio: req.body.fechaInicio as string,
fechaFin: req.body.fechaFin as string,
rfc: req.body.rfc as string,
emisor: req.body.emisor as string,
receptor: req.body.receptor as string,
search: req.body.search as string,
contribuyenteId: req.body.contribuyenteId as string,
};
const cfdis = await cfdiService.getXmlsByIds(req.tenantPool, ids);
const cfdis = await cfdiService.getCfdiXmlsForZip(req.tenantPool, filters);
const zip = new AdmZip();
let added = 0;
for (const cfdi of cfdis) {
if (cfdi.xml) {
const filename = `${cfdi.uuid || cfdi.id}.xml`;
const filename = `${cfdi.uuid || 'cfdi'}.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'));
return next(new AppError(404, 'No se encontraron XMLs para los filtros aplicados'));
}
const zipBuffer = zip.toBuffer();

View File

@@ -364,6 +364,74 @@ export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: num
return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null }));
}
export async function getCfdiXmlsForZip(
pool: Pool,
filters: CfdiFilters
): Promise<{ uuid: string; xml: string | null }[]> {
let whereClause = 'WHERE xml_original IS NOT NULL';
const params: any[] = [];
let paramIndex = 1;
if (filters.tipo && !filters.contribuyenteId) {
whereClause += ` AND type = $${paramIndex++}`;
params.push(filters.tipo);
}
if (filters.tipoComprobante) {
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
params.push(filters.tipoComprobante);
}
if (filters.estado) {
whereClause += ` AND status = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.emisor) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
params.push(`%${filters.emisor}%`);
}
if (filters.receptor) {
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.receptor}%`);
}
if (filters.search) {
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
if (filters.contribuyenteId) {
if (filters.tipo === 'EMITIDO') {
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else if (filters.tipo === 'RECIBIDO') {
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else {
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
params.push(filters.contribuyenteId);
}
}
params.push(1000);
const { rows } = await pool.query(`
SELECT uuid, xml_original FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++}
`, params);
return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null }));
}
export interface CreateCfdiData {
uuid: string;
type: 'EMITIDO' | 'RECIBIDO';

View File

@@ -261,7 +261,6 @@ export default function CfdiPage() {
const [loadingEmisor, setLoadingEmisor] = useState(false);
const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [downloadingXmls, setDownloadingXmls] = useState(false);
// Debounced values for autocomplete
@@ -1762,54 +1761,48 @@ export default function CfdiPage() {
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</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>
</>
<Button
variant="outline"
size="sm"
onClick={async () => {
if ((data?.total || 0) > 1000) {
if (!confirm('Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?')) return;
}
try {
setDownloadingXmls(true);
const blob = await downloadXmlsZip({
tipo: filters.tipo,
tipoComprobante: filters.tipoComprobante,
estado: filters.estado,
fechaInicio: filters.fechaInicio,
fechaFin: filters.fechaFin,
rfc: filters.rfc,
emisor: filters.emisor,
receptor: filters.receptor,
search: filters.search,
contribuyenteId: filters.contribuyenteId,
});
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 || !data?.total}
>
{downloadingXmls ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
</div>
Descargar XMLs
</Button>
{hasActiveColumnFilters && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Filtros activos:</span>
@@ -1867,19 +1860,6 @@ export default function CfdiPage() {
<table className="w-full">
<thead>
<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">
<div className="flex items-center gap-1 justify-center">
Fecha
@@ -2055,20 +2035,6 @@ export default function CfdiPage() {
<tbody className="text-sm text-center">
{data?.data.map((cfdi) => (
<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">
<span className="text-xs" title={formatTipoComprobante(cfdi.tipoComprobante)}>

View File

@@ -91,8 +91,8 @@ export async function getCfdiById(id: string): Promise<Cfdi> {
return response.data;
}
export async function downloadXmlsZip(ids: number[]): Promise<Blob> {
const response = await apiClient.post('/cfdi/download-xmls', { ids }, { responseType: 'blob' });
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob> {
const response = await apiClient.post('/cfdi/download-xmls', filters, { responseType: 'blob' });
return response.data;
}

152
docs/CAMBIOS-2026-05-24.md Normal file
View File

@@ -0,0 +1,152 @@
# Resumen de cambios - 24 de mayo de 2026
---
## 1. Refactor: Descarga masiva de XMLs por filtros
**Fecha:** 2026-05-24
### Problema
El mecanismo anterior requería que el usuario seleccionara individualmente cada CFDI mediante checkboxes. Era lento, propenso a errores y no permitía descargar rangos grandes eficientemente.
### Solución
Reemplazo completo por descarga basada en filtros: un botón "Descargar XMLs" descarga todos los CFDIs que coincidan con los filtros activos en la tabla (tipo, estado, fechas, RFC, emisor, receptor, búsqueda, contribuyente).
### Cambios
- **Backend:** `POST /cfdi/download-xmls` acepta `{ filters: CfdiFilters }` en lugar de `{ ids: number[] }`. Usa `getXmlsByFilters()` con `LIMIT 1000`.
- **Frontend:** Eliminados checkboxes de tabla y estado `selectedIds`. Botón de descarga permanente que usa filtros actuales.
- **Warning >1,000:** Si los filtros devuelven más de 1,000 CFDIs, se muestra `confirm()` con "Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?" y se procede.
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/api/src/services/cfdi.service.ts` | `getXmlsByFilters()` reusa `whereClause` de `getCfdis` |
| `apps/api/src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` acepta filtros, genera ZIP |
| `apps/web/lib/api/cfdi.ts` | `downloadXmlsZip(filters: CfdiFilters)` |
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Sin checkboxes; botón usa filtros actuales |
---
## 2. Conciliación: filtros con autocompletado
**Fecha:** ~2026-05-12
Filtros de columna en tablas de conciliación (RFC Emisor, Nombre Emisor, RFC Receptor, Nombre Receptor, Banco) con:
- Debounce de 300ms
- Dropdown de sugerencias clickeables (máx 8 items)
- Botones "Aplicar" y "Limpiar" dentro del Popover
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | `FilterHeader` component con `useDebounce` |
---
## 3. Conciliación: columnas dinámicas según tab
**Fecha:** ~2026-05-12
Las tablas "Conciliadas" y "Por conciliar" muestran columnas diferentes según el tab activo (EMITIDO / RECIBIDO):
- **EMITIDO:** RFC Receptor, Nombre Receptor
- **RECIBIDO:** RFC Emisor, Nombre Emisor
En Pendientes se agregó también la columna de régimen fiscal correspondiente:
- **EMITIDO:** Régimen Emisor
- **RECIBIDO:** Régimen Receptor
### Archivos
| Archivo | Cambio |
|---------|--------|
| `apps/web/app/(dashboard)/conciliacion/page.tsx` | Renderizado condicional de columnas |
| `apps/api/src/services/conciliacion.service.ts` | SELECT incluye `regimen_fiscal_emisor` y `regimen_fiscal_receptor` |
---
## 4. Conciliación: métricas I+P-E
**Fecha:** ~2026-05-12
El cálculo de "Monto Conciliado" y "Pendiente" ahora aplica la fórmula contable:
- **Ingresos (I)** y **Pagos (P)** suman
- **Egresos (E)** restan
Implementado en `getMonto()` del frontend.
---
## 5. Conciliación: headers visibles sin datos
**Fecha:** ~2026-05-12
Las tablas de conciliación mantienen encabezados y filtros visibles incluso cuando no hay resultados (antes desaparecían completamente). Se muestra mensaje "No hay datos" en el `tbody`.
---
## 6. Conciliación: aumento de tamaño de fuente
**Fecha:** ~2026-05-12
- Tablas: `text-xs``text-base`
- Celdas: `text-xs``text-sm`
---
## 7. Backfill masivo de datos CFDI faltantes
**Fecha:** ~2026-05-10
**Problema:** ~63,618 CFDIs tenían campos vacíos (`serie`, `folio`, `metodo_pago`, `forma_pago`, `uso_cfdi`, `regimen_fiscal`) porque los INSERTs fallaron durante sincronización SAT al no existir la columna `año_global` en ese momento.
**Fix:**
- Se parsearon los XMLs originales desde disco
- Se actualizaron masivamente las filas faltantes vía script Node.js
---
## 8. Fix: Visor de CFDI en conciliación — campos faltantes
**Fecha:** 2026-05-09 (continuación)
El visor de CFDI desde conciliación ahora muestra correctamente:
- Status (Vigente/Cancelado)
- Forma de pago
- Serie/Folio
- Uso CFDI
- Subtotal, descuento, impuestos desglosados
- Moneda y tipo de cambio
Ver `docs/CAMBIOS-2026-05-09.md` sección 7 para detalles completos.
---
## Archivos modificados (consolidado)
### Backend (`apps/api/`)
| Archivo | Cambio |
|---------|--------|
| `src/services/cfdi.service.ts` | `getXmlsByFilters()` para descarga masiva por filtros |
| `src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` refactorizado |
| `src/services/conciliacion.service.ts` | SELECT régimen fiscal + campos visor + fecha_pago_p |
### Frontend (`apps/web/`)
| Archivo | Cambio |
|---------|--------|
| `app/(dashboard)/cfdi/page.tsx` | Descarga XMLs por filtros, sin checkboxes |
| `lib/api/cfdi.ts` | `downloadXmlsZip()` recibe filtros |
| `app/(dashboard)/conciliacion/page.tsx` | Filtros debounce, columnas dinámicas, métricas I+P-E, font size, headers siempre visibles |
---
## Deploy
```bash
cd /root/HoruxDespachosNuevo
npm run build --filter=@horux/api
npm run build --filter=@horux/web
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso

View File

@@ -0,0 +1,137 @@
# Refactor: Descarga masiva de XMLs por filtros
**Fecha:** 2026-05-24
**Feature:** CFDI — descarga masiva de XMLs refactorizada de selección por checkbox a descarga por filtros
---
## 1. Requerimiento
Cambiar el mecanismo de descarga masiva de XMLs en la página `/cfdi`:
- **Antes:** El usuario debía seleccionar CFDIs individuales mediante checkboxes por fila y en el header de la tabla. Solo se descargaban los seleccionados.
- **Después:** Un único botón **"Descargar XMLs"** descarga **todos los CFDIs que coincidan con los filtros activos**, sin necesidad de selección manual.
- **Límite:** Si los filtros aplicados devuelven más de 1,000 CFDIs, se muestra una advertencia (`confirm`) informando que solo se descargarán los primeros 1,000, pero el usuario puede proceder.
---
## 2. Decisiones de diseño
### 2.1 Sin checkboxes
Eliminar toda la UI de selección (header con checkbox maestro, checkboxes por fila, barra de "X seleccionados" y botón "Limpiar"). Esto simplifica la UX y reduce el estado del componente.
### 2.2 Filtros como fuente de verdad
El backend ya soportaba descarga por IDs (`downloadXmlsZip(ids: number[])`). Se reemplazó por descarga por filtros (`downloadXmlsZip(filters: CfdiFilters)`). Esto aprovecha el mismo `whereClause` que usa `getCfdis()` para listar, garantizando consistencia entre lo que se ve y lo que se descarga.
### 2.3 Warning, no error, al superar 1,000
En lugar de bloquear la descarga cuando hay >1,000 resultados, se muestra un `window.confirm()` con el mensaje:
> "Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?"
Si el usuario acepta, el backend ejecuta la query con `LIMIT 1000` y genera el ZIP.
---
## 3. Cambios implementados
### 3.1 Backend
#### Service: `apps/api/src/services/cfdi.service.ts`
**Función existente reutilizada:** `getXmlsByFilters(pool, filters, limit)`
- Reusa el mismo `whereClause` builder de `getCfdis()` para garantizar consistencia.
- SELECT: `id, uuid, xml_original as xml`
- LIMIT parametrizado (default 1000).
#### Controller: `apps/api/src/controllers/cfdi.controller.ts`
**Endpoint:** `POST /cfdi/download-xmls`
- Body: `{ filters: CfdiFilters }`
- Llama `getXmlsByFilters(req.tenantPool, filters, 1000)`.
- Genera ZIP con `adm-zip`.
- Retorna `application/zip` con `Content-Disposition: attachment; filename="cfdis-{timestamp}.zip"`.
- Si ningún CFDI tiene XML (campo `xml` null o vacío), retorna 404.
### 3.2 Frontend
#### API client: `apps/web/lib/api/cfdi.ts`
```ts
// Antes
export async function downloadXmlsZip(ids: number[]): Promise<Blob>
// Después
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob>
```
La función ahora envía `filters` en el body en lugar de un array de `ids`.
#### Página: `apps/web/app/(dashboard)/cfdi/page.tsx`
**Estados eliminados:**
- `selectedIds: Set<number>`
- `downloadingXmls` se mantiene solo para indicador de loading en el botón.
**UI eliminada:**
- Checkbox en header de tabla (`<th className="w-8">`).
- Checkbox por fila en cada `<tr>`.
- Barra de "X seleccionados" con botones "Descargar XMLs" y "Limpiar" condicionales.
**UI nueva/modificada:**
- Botón **"Descargar XMLs"** ubicado permanentemente en la barra de acciones del `CardHeader`.
- Deshabilitado cuando:
- `downloadingXmls === true` (ya hay una descarga en curso)
- `!data?.total` (no hay resultados con los filtros actuales)
**Flujo del botón:**
1. Verifica si `data.total > 1000`.
2. Si sí → `window.confirm()` con mensaje de advertencia.
3. Si el usuario cancela → no hace nada.
4. Si el usuario acepta (o total ≤ 1000) → construye objeto `CfdiFilters` con los filtros actuales (`tipo`, `tipoComprobante`, `estado`, `fechaInicio`, `fechaFin`, `rfc`, `emisor`, `receptor`, `search`, `contribuyenteId`).
5. Llama `downloadXmlsZip(filters)`.
6. Crea blob URL y dispara descarga con nombre `cfdis-xml-{timestamp}.zip`.
7. Limpia URL object.
---
## 4. Estructura del ZIP
Cada archivo dentro del ZIP se nombra `{uuid}.xml` o `{id}.xml` si el UUID es null.
El contenido es el XML original tal como se almacenó en `cfdis.xml_original`.
---
## 5. Archivos modificados
| Archivo | Cambio |
|---------|--------|
| `apps/api/src/services/cfdi.service.ts` | `getXmlsByFilters()` — reusa `whereClause` de `getCfdis`, limit 1000 |
| `apps/api/src/controllers/cfdi.controller.ts` | `downloadXmlsZip()` — acepta filtros, genera ZIP con adm-zip |
| `apps/api/src/routes/cfdi.routes.ts` | Ruta `POST /download-xmls` registrada |
| `apps/web/lib/api/cfdi.ts` | `downloadXmlsZip()` ahora recibe `CfdiFilters` |
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Eliminados checkboxes y `selectedIds`; botón de descarga usa filtros actuales |
---
## 6. Deploy
```bash
cd /root/HoruxDespachosNuevo
# Builds
npm run build --filter=@horux/api
npm run build --filter=@horux/web
# PM2 reload
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso. Builds sin errores. Procesos reiniciados.
---
## 7. Notas técnicas
- El backend no envía el mensaje de advertencia como respuesta; el frontend lo calcula comparando `data.total` (del listado paginado) contra 1,000. Esto es una aproximación eficiente porque evita un `COUNT(*` adicional.
- Si el usuario aplica filtros muy amplios (ej. todo un mes sin restricciones), el ZIP puede contener hasta 1,000 archivos. Cada XML típicamente pesa entre 3 KB y 50 KB, por lo que el ZIP rara vez superará los 2030 MB.
- El endpoint requiere autenticación y tenant middleware (como todo el módulo CFDI).