diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index 0bb3aeb..7ecd4c0 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -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(); diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts index c7057b9..abc7067 100644 --- a/apps/api/src/services/cfdi.service.ts +++ b/apps/api/src/services/cfdi.service.ts @@ -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'; diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index cc741d7..10f5b08 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -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>(new Set()); const [downloadingXmls, setDownloadingXmls] = useState(false); // Debounced values for autocomplete @@ -1762,54 +1761,48 @@ export default function CfdiPage() { CFDIs ({data?.total || 0}) -
- {selectedIds.size > 0 && ( - <> - - {selectedIds.size} seleccionados - - - - +
+ Descargar XMLs + {hasActiveColumnFilters && (
Filtros activos: @@ -1867,19 +1860,6 @@ export default function CfdiPage() { - {data?.data.map((cfdi) => ( - `. +- 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).
- { - if (selectedIds.size === data?.data.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(data?.data.map((c: any) => c.id) || [])); - } - }} - /> -
Fecha @@ -2055,20 +2035,6 @@ 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 aa6a9c9..2d4dcb9 100644 --- a/apps/web/lib/api/cfdi.ts +++ b/apps/web/lib/api/cfdi.ts @@ -91,8 +91,8 @@ 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' }); +export async function downloadXmlsZip(filters: CfdiFilters): Promise { + const response = await apiClient.post('/cfdi/download-xmls', filters, { responseType: 'blob' }); return response.data; } diff --git a/docs/CAMBIOS-2026-05-24.md b/docs/CAMBIOS-2026-05-24.md new file mode 100644 index 0000000..c3d7764 --- /dev/null +++ b/docs/CAMBIOS-2026-05-24.md @@ -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 diff --git a/docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md b/docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md new file mode 100644 index 0000000..7e20502 --- /dev/null +++ b/docs/sessions/2026-05-24-cfdi-bulk-xml-download-refactor.md @@ -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 + +// Después +export async function downloadXmlsZip(filters: CfdiFilters): Promise +``` + +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` +- `downloadingXmls` se mantiene solo para indicador de loading en el botón. + +**UI eliminada:** +- Checkbox en header de tabla (``). +- Checkbox por fila en cada `