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

@@ -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<number> {
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<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);
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<BatchInsertResult> {
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<void> {