Compare commits
2 Commits
80e2c099d9
...
e35eae2a72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35eae2a72 | ||
|
|
5c940847af |
@@ -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,50 @@ 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 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.getCfdiXmlsForZip(req.tenantPool, filters);
|
||||
const zip = new AdmZip();
|
||||
let added = 0;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
if (cfdi.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 filtros aplicados'));
|
||||
}
|
||||
|
||||
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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | 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 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';
|
||||
|
||||
@@ -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,7 @@ export default function CfdiPage() {
|
||||
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [downloadingXmls, setDownloadingXmls] = useState(false);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
@@ -1760,6 +1761,48 @@ export default function CfdiPage() {
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</CardTitle>
|
||||
<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" />
|
||||
)}
|
||||
Descargar XMLs
|
||||
</Button>
|
||||
{hasActiveColumnFilters && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Filtros activos:</span>
|
||||
|
||||
@@ -91,6 +91,11 @@ export async function getCfdiById(id: string): Promise<Cfdi> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function downloadXmlsZip(filters: CfdiFilters): Promise<Blob> {
|
||||
const response = await apiClient.post('/cfdi/download-xmls', filters, { 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());
|
||||
|
||||
152
docs/CAMBIOS-2026-05-24.md
Normal file
152
docs/CAMBIOS-2026-05-24.md
Normal 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
|
||||
137
docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md
Normal file
137
docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md
Normal 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 20–30 MB.
|
||||
- El endpoint requiere autenticación y tenant middleware (como todo el módulo CFDI).
|
||||
Reference in New Issue
Block a user