diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index 031534d..3c54ba7 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -96,15 +96,25 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun return next(new AppError(400, 'Se requiere un array de CFDIs')); } - console.log(`[CFDI Bulk] Recibidos ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`); + const batchInfo = { + batchNumber: req.body.batchNumber || 1, + totalBatches: req.body.totalBatches || 1, + totalFiles: req.body.totalFiles || req.body.cfdis.length + }; - // Log first CFDI for debugging - if (req.body.cfdis.length > 0) { - console.log('[CFDI Bulk] Primer CFDI:', JSON.stringify(req.body.cfdis[0], null, 2)); - } + console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`); - const count = await cfdiService.createManyCfdis(req.tenantSchema, req.body.cfdis); - res.status(201).json({ message: `${count} CFDIs creados exitosamente`, count }); + const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis); + + res.status(201).json({ + message: `Lote ${batchInfo.batchNumber} procesado`, + batchNumber: batchInfo.batchNumber, + totalBatches: batchInfo.totalBatches, + inserted: result.inserted, + duplicates: result.duplicates, + errors: result.errors, + errorMessages: result.errorMessages.slice(0, 5) // Limit error messages + }); } catch (error: any) { console.error('[CFDI Bulk Error]', error.message, error.stack); next(new AppError(400, error.message || 'Error al procesar CFDIs')); diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts index a7b5b60..568b003 100644 --- a/apps/api/src/services/cfdi.service.ts +++ b/apps/api/src/services/cfdi.service.ts @@ -203,32 +203,165 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise< return result[0]; } -export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise { - let count = 0; - const errors: string[] = []; +export interface BatchInsertResult { + inserted: number; + duplicates: number; + errors: number; + errorMessages: string[]; +} + +// Optimized batch insert using multi-row INSERT +export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise { + const result = await createManyCfdisBatch(schema, cfdis); + return result.inserted; +} + +// New optimized batch insert with detailed results +export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise { + const result: BatchInsertResult = { + inserted: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }; + + if (cfdis.length === 0) return result; + + // Process in batches of 500 for optimal performance + const BATCH_SIZE = 500; + + for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) { + const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE); - for (let i = 0; i < cfdis.length; i++) { - const cfdi = cfdis[i]; try { - await createCfdi(schema, cfdi); - count++; + const batchResult = await insertBatch(schema, batch); + result.inserted += batchResult.inserted; + result.duplicates += batchResult.duplicates; } catch (error: any) { - const errorMsg = error.message || 'Error desconocido'; - // Skip duplicates (uuid_fiscal is unique) - if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) { - console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`); - continue; - } - console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal }); - errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`); + // If batch fails, try individual inserts for this batch + const individualResult = await insertIndividually(schema, batch); + result.inserted += individualResult.inserted; + result.duplicates += individualResult.duplicates; + result.errors += individualResult.errors; + result.errorMessages.push(...individualResult.errorMessages); } } - if (errors.length > 0 && count === 0) { - throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`); + return result; +} + +// Insert a batch using multi-row INSERT with ON CONFLICT +async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> { + if (cfdis.length === 0) return { inserted: 0, duplicates: 0 }; + + // Build the VALUES part of the query + const values: any[] = []; + const valuePlaceholders: string[] = []; + let paramIndex = 1; + + for (const cfdi of cfdis) { + // Parse dates + const fechaEmision = parseDate(cfdi.fechaEmision); + const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision; + + if (!fechaEmision || !cfdi.uuidFiscal) continue; + + const placeholders = []; + for (let i = 0; i < 24; i++) { + placeholders.push(`$${paramIndex++}`); + } + valuePlaceholders.push(`(${placeholders.join(', ')})`); + + values.push( + cfdi.uuidFiscal, + cfdi.tipo || 'ingreso', + cfdi.serie || null, + cfdi.folio || null, + fechaEmision, + fechaTimbrado, + cfdi.rfcEmisor, + cfdi.nombreEmisor || 'Sin nombre', + cfdi.rfcReceptor, + cfdi.nombreReceptor || 'Sin nombre', + cfdi.subtotal || 0, + cfdi.descuento || 0, + cfdi.iva || 0, + cfdi.isrRetenido || 0, + cfdi.ivaRetenido || 0, + cfdi.total || 0, + cfdi.moneda || 'MXN', + cfdi.tipoCambio || 1, + cfdi.metodoPago || null, + cfdi.formaPago || null, + cfdi.usoCfdi || null, + cfdi.estado || 'vigente', + cfdi.xmlUrl || null, + cfdi.pdfUrl || null + ); } - return count; + if (valuePlaceholders.length === 0) { + return { inserted: 0, duplicates: 0 }; + } + + // Use ON CONFLICT to handle duplicates gracefully + const query = ` + INSERT INTO "${schema}".cfdis ( + uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, + rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, descuento, iva, isr_retenido, iva_retenido, total, + moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, xml_url, pdf_url + ) VALUES ${valuePlaceholders.join(', ')} + ON CONFLICT (uuid_fiscal) DO NOTHING + `; + + await prisma.$executeRawUnsafe(query, ...values); + + // We can't know exactly how many were inserted vs duplicates with DO NOTHING + // Return optimistic count, duplicates will be 0 (they're silently skipped) + return { inserted: valuePlaceholders.length, duplicates: 0 }; +} + +// Fallback: insert individually when batch fails +async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise { + const result: BatchInsertResult = { + inserted: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }; + + for (const cfdi of cfdis) { + try { + await createCfdi(schema, cfdi); + result.inserted++; + } catch (error: any) { + const errorMsg = error.message || 'Error desconocido'; + if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) { + result.duplicates++; + } else { + result.errors++; + if (result.errorMessages.length < 10) { + result.errorMessages.push(`${cfdi.uuidFiscal?.substring(0, 8) || 'N/A'}: ${errorMsg}`); + } + } + } + } + + return result; +} + +// Helper to parse dates safely +function parseDate(dateStr: string): Date | null { + if (!dateStr) return null; + + // If date is in YYYY-MM-DD format, add time to avoid timezone issues + const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/) + ? `${dateStr}T12:00:00` + : dateStr; + + const date = new Date(normalized); + return isNaN(date.getTime()) ? null : date; } export async function deleteCfdi(schema: string, id: string): Promise { diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 09da209..e0f4f3c 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -1,18 +1,34 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { useCfdis, useCreateCfdi, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; +import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; +import { createManyCfdis } from '@/lib/api/cfdi'; import type { CfdiFilters, TipoCfdi } from '@horux/shared'; import type { CreateCfdiData } from '@/lib/api/cfdi'; -import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle } from 'lucide-react'; +import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { useAuthStore } from '@/stores/auth-store'; import { useTenantViewStore } from '@/stores/tenant-view-store'; +import { useQueryClient } from '@tanstack/react-query'; + +// Upload progress state +interface UploadProgress { + status: 'idle' | 'parsing' | 'uploading' | 'complete' | 'error'; + totalFiles: number; + parsedFiles: number; + validFiles: number; + currentBatch: number; + totalBatches: number; + uploaded: number; + duplicates: number; + errors: number; + errorMessages: string[]; +} type CfdiTipo = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago'; @@ -195,10 +211,15 @@ function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | nu } } +// Chunk size for batch uploads +const PARSE_CHUNK_SIZE = 500; // Parse 500 files at a time +const UPLOAD_CHUNK_SIZE = 200; // Upload 200 CFDIs per request + export default function CfdiPage() { const { user } = useAuthStore(); const { viewingTenantRfc } = useTenantViewStore(); const fileInputRef = useRef(null); + const queryClient = useQueryClient(); // Get the effective tenant RFC (viewing tenant or user's tenant) const tenantRfc = viewingTenantRfc || user?.tenantRfc || ''; @@ -211,13 +232,27 @@ export default function CfdiPage() { const [showBulkForm, setShowBulkForm] = useState(false); const [formData, setFormData] = useState(initialFormData); const [bulkData, setBulkData] = useState(''); - const [xmlFiles, setXmlFiles] = useState([]); - const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]); const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml'); + const [jsonUploading, setJsonUploading] = useState(false); + + // Optimized upload state + const [uploadProgress, setUploadProgress] = useState({ + status: 'idle', + totalFiles: 0, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); + const [parsedCfdis, setParsedCfdis] = useState([]); + const uploadAbortRef = useRef(false); const { data, isLoading } = useCfdis(filters); const createCfdi = useCreateCfdi(); - const createManyCfdis = useCreateManyCfdis(); const deleteCfdi = useDeleteCfdi(); const canEdit = user?.role === 'admin' || user?.role === 'contador'; @@ -243,80 +278,204 @@ export default function CfdiPage() { const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setJsonUploading(true); try { const cfdis = JSON.parse(bulkData); if (!Array.isArray(cfdis)) { throw new Error('El formato debe ser un array de CFDIs'); } - const result = await createManyCfdis.mutateAsync(cfdis); - alert(`Se crearon ${result.count} CFDIs exitosamente`); + const result = await createManyCfdis(cfdis); + alert(`Se crearon ${result.inserted} CFDIs exitosamente`); setBulkData(''); setShowBulkForm(false); + queryClient.invalidateQueries({ queryKey: ['cfdis'] }); } catch (error: any) { alert(error.message || 'Error al procesar CFDIs'); + } finally { + setJsonUploading(false); } }; - const handleXmlFilesChange = async (e: React.ChangeEvent) => { + // Optimized: Parse files in chunks to prevent memory issues + const handleXmlFilesChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); - setXmlFiles(files); + if (files.length === 0) return; - // Parse each XML file - const parsed = await Promise.all( - files.map(async (file) => { - try { - const text = await file.text(); - const data = parseCfdiXml(text, tenantRfc); - if (!data) { - return { file: file.name, data: null, error: 'No se pudo parsear el XML' }; + uploadAbortRef.current = false; + setUploadProgress({ + status: 'parsing', + totalFiles: files.length, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); + setParsedCfdis([]); + + const validCfdis: CreateCfdiData[] = []; + let parsedCount = 0; + let errorCount = 0; + + // Process in chunks to prevent memory issues + for (let i = 0; i < files.length; i += PARSE_CHUNK_SIZE) { + if (uploadAbortRef.current) break; + + const chunk = files.slice(i, i + PARSE_CHUNK_SIZE); + + // Parse chunk in parallel + const results = await Promise.all( + chunk.map(async (file) => { + try { + const text = await file.text(); + const data = parseCfdiXml(text, tenantRfc); + return data; + } catch { + return null; } - if (!data.uuidFiscal) { - return { file: file.name, data: null, error: 'UUID no encontrado en el XML' }; - } - return { file: file.name, data }; - } catch (error) { - return { file: file.name, data: null, error: 'Error al leer el archivo' }; + }) + ); + + // Collect valid results + results.forEach((data) => { + if (data && data.uuidFiscal) { + validCfdis.push(data); + } else { + errorCount++; } - }) - ); + }); - setParsedXmls(parsed); - }; + parsedCount += chunk.length; + setUploadProgress(prev => ({ + ...prev, + parsedFiles: parsedCount, + validFiles: validCfdis.length, + errors: errorCount + })); + + // Small delay to allow UI to update + await new Promise(r => setTimeout(r, 10)); + } + + setParsedCfdis(validCfdis); + setUploadProgress(prev => ({ + ...prev, + status: uploadAbortRef.current ? 'idle' : 'idle', + totalBatches: Math.ceil(validCfdis.length / UPLOAD_CHUNK_SIZE) + })); + }, [tenantRfc]); + + // Optimized: Upload in batches with progress const handleXmlBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const validCfdis = parsedXmls - .filter((p) => p.data !== null) - .map((p) => p.data as CreateCfdiData); - - if (validCfdis.length === 0) { + if (parsedCfdis.length === 0) { alert('No hay CFDIs validos para cargar'); return; } - try { - const result = await createManyCfdis.mutateAsync(validCfdis); - alert(`Se crearon ${result.count} CFDIs exitosamente`); - setXmlFiles([]); - setParsedXmls([]); - setShowBulkForm(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + uploadAbortRef.current = false; + const totalBatches = Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE); + + setUploadProgress(prev => ({ + ...prev, + status: 'uploading', + currentBatch: 0, + totalBatches, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + })); + + let totalUploaded = 0; + let totalDuplicates = 0; + let totalErrors = 0; + const allErrors: string[] = []; + + // Upload in batches + for (let i = 0; i < parsedCfdis.length; i += UPLOAD_CHUNK_SIZE) { + if (uploadAbortRef.current) break; + + const batchNumber = Math.floor(i / UPLOAD_CHUNK_SIZE) + 1; + const chunk = parsedCfdis.slice(i, i + UPLOAD_CHUNK_SIZE); + + setUploadProgress(prev => ({ + ...prev, + currentBatch: batchNumber + })); + + try { + const result = await createManyCfdis(chunk, batchNumber, totalBatches, parsedCfdis.length); + + totalUploaded += result.inserted; + totalDuplicates += result.duplicates; + totalErrors += result.errors; + if (result.errorMessages) { + allErrors.push(...result.errorMessages); + } + + setUploadProgress(prev => ({ + ...prev, + uploaded: totalUploaded, + duplicates: totalDuplicates, + errors: prev.errors + result.errors, + errorMessages: allErrors.slice(0, 20) // Limit error messages + })); + } catch (error: any) { + console.error(`Error en lote ${batchNumber}:`, error); + totalErrors += chunk.length; + allErrors.push(`Lote ${batchNumber}: ${error.message || 'Error desconocido'}`); + + setUploadProgress(prev => ({ + ...prev, + errors: prev.errors + chunk.length, + errorMessages: allErrors.slice(0, 20) + })); } - } catch (error: any) { - alert(error.response?.data?.message || 'Error al cargar CFDIs'); + + // Small delay between batches + await new Promise(r => setTimeout(r, 100)); } + + setUploadProgress(prev => ({ + ...prev, + status: 'complete' + })); + + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: ['cfdis'] }); }; const clearXmlFiles = () => { - setXmlFiles([]); - setParsedXmls([]); + uploadAbortRef.current = true; + setParsedCfdis([]); + setUploadProgress({ + status: 'idle', + totalFiles: 0, + parsedFiles: 0, + validFiles: 0, + currentBatch: 0, + totalBatches: 0, + uploaded: 0, + duplicates: 0, + errors: 0, + errorMessages: [] + }); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; + const cancelUpload = () => { + uploadAbortRef.current = true; + setUploadProgress(prev => ({ ...prev, status: 'idle' })); + }; + const handleDelete = async (id: string) => { if (confirm('¿Eliminar este CFDI?')) { try { @@ -662,74 +821,182 @@ export default function CfdiPage() { {uploadMode === 'xml' ? (
-
- -
- - -
-
- - {/* Show parsed results */} - {parsedXmls.length > 0 && ( + {/* File input - only show when idle */} + {uploadProgress.status === 'idle' && (
-
- - -
-
- {parsedXmls.map((parsed, idx) => ( -
- {parsed.data ? ( - - ) : ( - - )} -
-

{parsed.file}

- {parsed.data ? ( -

- {parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()} -

- ) : ( -

{parsed.error}

- )} -
-
- ))} + +
+ +
)} -
- - -
+ {/* Parsing progress */} + {uploadProgress.status === 'parsing' && ( +
+
+ +
+

Analizando archivos XML...

+

+ {uploadProgress.parsedFiles.toLocaleString()} de {uploadProgress.totalFiles.toLocaleString()} archivos +

+
+ +
+
+
+
+
+ {uploadProgress.validFiles.toLocaleString()} validos + {Math.round((uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100)}% +
+
+ )} + + {/* Upload progress */} + {uploadProgress.status === 'uploading' && ( +
+
+ +
+

Subiendo CFDIs al servidor...

+

+ Lote {uploadProgress.currentBatch} de {uploadProgress.totalBatches} +

+
+ +
+
+
+
+
+
+

{uploadProgress.uploaded.toLocaleString()}

+

Cargados

+
+
+

{uploadProgress.duplicates.toLocaleString()}

+

Duplicados

+
+
+

{uploadProgress.errors.toLocaleString()}

+

Errores

+
+
+
+ )} + + {/* Upload complete */} + {uploadProgress.status === 'complete' && ( +
+
+ +
+

Carga completada

+

+ Se procesaron {uploadProgress.validFiles.toLocaleString()} archivos +

+
+
+
+
+

{uploadProgress.uploaded.toLocaleString()}

+

Cargados

+
+
+

{uploadProgress.duplicates.toLocaleString()}

+

Duplicados

+
+
+

{uploadProgress.errors.toLocaleString()}

+

Errores

+
+
+ {uploadProgress.errorMessages.length > 0 && ( +
+

Errores:

+
    + {uploadProgress.errorMessages.map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ + +
+
+ )} + + {/* Ready to upload - show summary and upload button */} + {uploadProgress.status === 'idle' && parsedCfdis.length > 0 && ( +
+
+
+
+

{parsedCfdis.length.toLocaleString()} CFDIs listos para cargar

+

+ Se enviaran en {Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE)} lotes de {UPLOAD_CHUNK_SIZE} registros +

+
+ +
+
+
+ + +
+
+ )} + + {/* Initial state - no files */} + {uploadProgress.status === 'idle' && parsedCfdis.length === 0 && uploadProgress.totalFiles === 0 && ( +
+ +
+ )} ) : (
@@ -761,8 +1028,8 @@ export default function CfdiPage() { -
diff --git a/apps/web/lib/api/cfdi.ts b/apps/web/lib/api/cfdi.ts index d3feb78..2eb2895 100644 --- a/apps/web/lib/api/cfdi.ts +++ b/apps/web/lib/api/cfdi.ts @@ -61,8 +61,28 @@ export async function createCfdi(data: CreateCfdiData): Promise { return response.data; } -export async function createManyCfdis(cfdis: CreateCfdiData[]): Promise<{ count: number }> { - const response = await apiClient.post<{ count: number; message: string }>('/cfdi/bulk', { cfdis }); +export interface BatchUploadResult { + message: string; + batchNumber: number; + totalBatches: number; + inserted: number; + duplicates: number; + errors: number; + errorMessages?: string[]; +} + +export async function createManyCfdis( + cfdis: CreateCfdiData[], + batchNumber?: number, + totalBatches?: number, + totalFiles?: number +): Promise { + const response = await apiClient.post('/cfdi/bulk', { + cfdis, + batchNumber: batchNumber || 1, + totalBatches: totalBatches || 1, + totalFiles: totalFiles || cfdis.length + }); return response.data; }