perf: optimize bulk XML upload for 100k+ files

Backend:
- Add batch insert using multi-row INSERT with ON CONFLICT
- Process in batches of 500 records for optimal DB performance
- Return detailed batch results (inserted, duplicates, errors)

Frontend:
- Parse files in chunks of 500 to prevent memory issues
- Upload in batches of 200 CFDIs per request
- Add detailed progress bar with real-time stats
- Show upload statistics (loaded, duplicates, errors)
- Add cancel functionality during upload
- Refresh data after upload completes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 07:01:04 +00:00
parent c3ce7199af
commit db1f2eaecd
4 changed files with 567 additions and 137 deletions

View File

@@ -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')); 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 console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
if (req.body.cfdis.length > 0) {
console.log('[CFDI Bulk] Primer CFDI:', JSON.stringify(req.body.cfdis[0], null, 2));
}
const count = await cfdiService.createManyCfdis(req.tenantSchema, req.body.cfdis); const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis);
res.status(201).json({ message: `${count} CFDIs creados exitosamente`, count });
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) { } catch (error: any) {
console.error('[CFDI Bulk Error]', error.message, error.stack); console.error('[CFDI Bulk Error]', error.message, error.stack);
next(new AppError(400, error.message || 'Error al procesar CFDIs')); next(new AppError(400, error.message || 'Error al procesar CFDIs'));

View File

@@ -203,32 +203,165 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
return result[0]; return result[0];
} }
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> { export interface BatchInsertResult {
let count = 0; inserted: number;
const errors: string[] = []; duplicates: number;
errors: number;
errorMessages: string[];
}
for (let i = 0; i < cfdis.length; i++) { // Optimized batch insert using multi-row INSERT
const cfdi = cfdis[i]; export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
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<BatchInsertResult> {
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);
try {
const batchResult = await insertBatch(schema, batch);
result.inserted += batchResult.inserted;
result.duplicates += batchResult.duplicates;
} catch (error: any) {
// 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);
}
}
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
);
}
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<BatchInsertResult> {
const result: BatchInsertResult = {
inserted: 0,
duplicates: 0,
errors: 0,
errorMessages: []
};
for (const cfdi of cfdis) {
try { try {
await createCfdi(schema, cfdi); await createCfdi(schema, cfdi);
count++; result.inserted++;
} catch (error: any) { } catch (error: any) {
const errorMsg = error.message || 'Error desconocido'; const errorMsg = error.message || 'Error desconocido';
// Skip duplicates (uuid_fiscal is unique)
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) { if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`); result.duplicates++;
continue; } else {
result.errors++;
if (result.errorMessages.length < 10) {
result.errorMessages.push(`${cfdi.uuidFiscal?.substring(0, 8) || 'N/A'}: ${errorMsg}`);
}
} }
console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal });
errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`);
} }
} }
if (errors.length > 0 && count === 0) { return result;
throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`); }
}
return count; // 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<void> { export async function deleteCfdi(schema: string, id: string): Promise<void> {

View File

@@ -1,18 +1,34 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Header } from '@/components/layouts/header'; import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { CfdiFilters, TipoCfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi'; 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 { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-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'; 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() { export default function CfdiPage() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const { viewingTenantRfc } = useTenantViewStore(); const { viewingTenantRfc } = useTenantViewStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
// Get the effective tenant RFC (viewing tenant or user's tenant) // Get the effective tenant RFC (viewing tenant or user's tenant)
const tenantRfc = viewingTenantRfc || user?.tenantRfc || ''; const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
@@ -211,13 +232,27 @@ export default function CfdiPage() {
const [showBulkForm, setShowBulkForm] = useState(false); const [showBulkForm, setShowBulkForm] = useState(false);
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData); const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
const [bulkData, setBulkData] = useState(''); const [bulkData, setBulkData] = useState('');
const [xmlFiles, setXmlFiles] = useState<File[]>([]);
const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]);
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml'); const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
const [jsonUploading, setJsonUploading] = useState(false);
// Optimized upload state
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
status: 'idle',
totalFiles: 0,
parsedFiles: 0,
validFiles: 0,
currentBatch: 0,
totalBatches: 0,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
});
const [parsedCfdis, setParsedCfdis] = useState<CreateCfdiData[]>([]);
const uploadAbortRef = useRef(false);
const { data, isLoading } = useCfdis(filters); const { data, isLoading } = useCfdis(filters);
const createCfdi = useCreateCfdi(); const createCfdi = useCreateCfdi();
const createManyCfdis = useCreateManyCfdis();
const deleteCfdi = useDeleteCfdi(); const deleteCfdi = useDeleteCfdi();
const canEdit = user?.role === 'admin' || user?.role === 'contador'; const canEdit = user?.role === 'admin' || user?.role === 'contador';
@@ -243,80 +278,204 @@ export default function CfdiPage() {
const handleBulkSubmit = async (e: React.FormEvent) => { const handleBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setJsonUploading(true);
try { try {
const cfdis = JSON.parse(bulkData); const cfdis = JSON.parse(bulkData);
if (!Array.isArray(cfdis)) { if (!Array.isArray(cfdis)) {
throw new Error('El formato debe ser un array de CFDIs'); throw new Error('El formato debe ser un array de CFDIs');
} }
const result = await createManyCfdis.mutateAsync(cfdis); const result = await createManyCfdis(cfdis);
alert(`Se crearon ${result.count} CFDIs exitosamente`); alert(`Se crearon ${result.inserted} CFDIs exitosamente`);
setBulkData(''); setBulkData('');
setShowBulkForm(false); setShowBulkForm(false);
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
} catch (error: any) { } catch (error: any) {
alert(error.message || 'Error al procesar CFDIs'); alert(error.message || 'Error al procesar CFDIs');
} finally {
setJsonUploading(false);
} }
}; };
const handleXmlFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => { // Optimized: Parse files in chunks to prevent memory issues
const handleXmlFilesChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
setXmlFiles(files); if (files.length === 0) return;
// Parse each XML file uploadAbortRef.current = false;
const parsed = await Promise.all( setUploadProgress({
files.map(async (file) => { 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 { try {
const text = await file.text(); const text = await file.text();
const data = parseCfdiXml(text, tenantRfc); const data = parseCfdiXml(text, tenantRfc);
if (!data) { return data;
return { file: file.name, data: null, error: 'No se pudo parsear el XML' }; } 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' };
} }
}) })
); );
setParsedXmls(parsed); // Collect valid results
}; results.forEach((data) => {
if (data && data.uuidFiscal) {
validCfdis.push(data);
} else {
errorCount++;
}
});
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) => { const handleXmlBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const validCfdis = parsedXmls if (parsedCfdis.length === 0) {
.filter((p) => p.data !== null)
.map((p) => p.data as CreateCfdiData);
if (validCfdis.length === 0) {
alert('No hay CFDIs validos para cargar'); alert('No hay CFDIs validos para cargar');
return; return;
} }
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 { try {
const result = await createManyCfdis.mutateAsync(validCfdis); const result = await createManyCfdis(chunk, batchNumber, totalBatches, parsedCfdis.length);
alert(`Se crearon ${result.count} CFDIs exitosamente`);
setXmlFiles([]); totalUploaded += result.inserted;
setParsedXmls([]); totalDuplicates += result.duplicates;
setShowBulkForm(false); totalErrors += result.errors;
if (fileInputRef.current) { if (result.errorMessages) {
fileInputRef.current.value = ''; 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) { } catch (error: any) {
alert(error.response?.data?.message || 'Error al cargar CFDIs'); 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)
}));
} }
// 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 = () => { const clearXmlFiles = () => {
setXmlFiles([]); uploadAbortRef.current = true;
setParsedXmls([]); setParsedCfdis([]);
setUploadProgress({
status: 'idle',
totalFiles: 0,
parsedFiles: 0,
validFiles: 0,
currentBatch: 0,
totalBatches: 0,
uploaded: 0,
duplicates: 0,
errors: 0,
errorMessages: []
});
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
const cancelUpload = () => {
uploadAbortRef.current = true;
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (confirm('¿Eliminar este CFDI?')) { if (confirm('¿Eliminar este CFDI?')) {
try { try {
@@ -662,6 +821,8 @@ export default function CfdiPage() {
{uploadMode === 'xml' ? ( {uploadMode === 'xml' ? (
<form onSubmit={handleXmlBulkSubmit} className="space-y-4"> <form onSubmit={handleXmlBulkSubmit} className="space-y-4">
{/* File input - only show when idle */}
{uploadProgress.status === 'idle' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Archivos XML de CFDI</Label> <Label>Archivos XML de CFDI</Label>
<div className="border-2 border-dashed rounded-lg p-6 text-center"> <div className="border-2 border-dashed rounded-lg p-6 text-center">
@@ -677,59 +838,165 @@ export default function CfdiPage() {
<label htmlFor="xml-upload" className="cursor-pointer"> <label htmlFor="xml-upload" className="cursor-pointer">
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" /> <FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Haz clic para seleccionar archivos XML o arrastralos aqui Haz clic para seleccionar archivos XML
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Puedes seleccionar multiples archivos Optimizado para cargas masivas (+100,000 archivos)
</p> </p>
</label> </label>
</div> </div>
</div> </div>
)}
{/* Show parsed results */} {/* Parsing progress */}
{parsedXmls.length > 0 && ( {uploadProgress.status === 'parsing' && (
<div className="space-y-2"> <div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">Analizando archivos XML...</p>
<p className="text-sm text-muted-foreground">
{uploadProgress.parsedFiles.toLocaleString()} de {uploadProgress.totalFiles.toLocaleString()} archivos
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={cancelUpload}>
Cancelar
</Button>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{uploadProgress.validFiles.toLocaleString()} validos</span>
<span>{Math.round((uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100)}%</span>
</div>
</div>
)}
{/* Upload progress */}
{uploadProgress.status === 'uploading' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">Subiendo CFDIs al servidor...</p>
<p className="text-sm text-muted-foreground">
Lote {uploadProgress.currentBatch} de {uploadProgress.totalBatches}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={cancelUpload}>
Cancelar
</Button>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${(uploadProgress.currentBatch / uploadProgress.totalBatches) * 100}%` }}
/>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-2xl font-bold text-success">{uploadProgress.uploaded.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Cargados</p>
</div>
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-2xl font-bold text-warning">{uploadProgress.duplicates.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Duplicados</p>
</div>
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-2xl font-bold text-destructive">{uploadProgress.errors.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Errores</p>
</div>
</div>
</div>
)}
{/* Upload complete */}
{uploadProgress.status === 'complete' && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-success" />
<div className="flex-1">
<p className="font-medium text-success">Carga completada</p>
<p className="text-sm text-muted-foreground">
Se procesaron {uploadProgress.validFiles.toLocaleString()} archivos
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-2xl font-bold text-success">{uploadProgress.uploaded.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Cargados</p>
</div>
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-2xl font-bold text-warning">{uploadProgress.duplicates.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Duplicados</p>
</div>
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-2xl font-bold text-destructive">{uploadProgress.errors.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Errores</p>
</div>
</div>
{uploadProgress.errorMessages.length > 0 && (
<div className="p-3 bg-destructive/10 rounded-lg">
<p className="text-sm font-medium text-destructive mb-2">Errores:</p>
<ul className="text-xs text-destructive space-y-1 max-h-24 overflow-y-auto">
{uploadProgress.errorMessages.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={clearXmlFiles}>
Cargar mas archivos
</Button>
<Button type="button" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cerrar
</Button>
</div>
</div>
)}
{/* Ready to upload - show summary and upload button */}
{uploadProgress.status === 'idle' && parsedCfdis.length > 0 && (
<div className="space-y-4">
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Archivos procesados ({parsedXmls.filter(p => p.data).length} validos de {parsedXmls.length})</Label> <div>
<p className="font-medium">{parsedCfdis.length.toLocaleString()} CFDIs listos para cargar</p>
<p className="text-sm text-muted-foreground">
Se enviaran en {Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE)} lotes de {UPLOAD_CHUNK_SIZE} registros
</p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}> <Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
Limpiar Limpiar
</Button> </Button>
</div> </div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{parsedXmls.map((parsed, idx) => (
<div key={idx} className="p-2 flex items-center gap-2 text-sm">
{parsed.data ? (
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{parsed.file}</p>
{parsed.data ? (
<p className="text-xs text-muted-foreground">
{parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()}
</p>
) : (
<p className="text-xs text-destructive">{parsed.error}</p>
)}
</div> </div>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}> <Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cancelar Cancelar
</Button> </Button>
<Button <Button type="submit">
type="submit" <Upload className="h-4 w-4 mr-2" />
disabled={createManyCfdis.isPending || parsedXmls.filter(p => p.data).length === 0} Iniciar carga
>
{createManyCfdis.isPending ? 'Procesando...' : `Cargar ${parsedXmls.filter(p => p.data).length} CFDIs`}
</Button> </Button>
</div> </div>
</div>
)}
{/* Initial state - no files */}
{uploadProgress.status === 'idle' && parsedCfdis.length === 0 && uploadProgress.totalFiles === 0 && (
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar
</Button>
</div>
)}
</form> </form>
) : ( ) : (
<form onSubmit={handleBulkSubmit} className="space-y-4"> <form onSubmit={handleBulkSubmit} className="space-y-4">
@@ -761,8 +1028,8 @@ export default function CfdiPage() {
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}> <Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" disabled={createManyCfdis.isPending}> <Button type="submit" disabled={jsonUploading}>
{createManyCfdis.isPending ? 'Procesando...' : 'Cargar CFDIs'} {jsonUploading ? 'Procesando...' : 'Cargar CFDIs'}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -61,8 +61,28 @@ export async function createCfdi(data: CreateCfdiData): Promise<Cfdi> {
return response.data; return response.data;
} }
export async function createManyCfdis(cfdis: CreateCfdiData[]): Promise<{ count: number }> { export interface BatchUploadResult {
const response = await apiClient.post<{ count: number; message: string }>('/cfdi/bulk', { cfdis }); 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<BatchUploadResult> {
const response = await apiClient.post<BatchUploadResult>('/cfdi/bulk', {
cfdis,
batchNumber: batchNumber || 1,
totalBatches: totalBatches || 1,
totalFiles: totalFiles || cfdis.length
});
return response.data; return response.data;
} }