import { prisma } from '../config/database.js'; import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; export async function getCfdis(schema: string, filters: CfdiFilters): Promise { const page = filters.page || 1; const limit = filters.limit || 20; const offset = (page - 1) * limit; let whereClause = 'WHERE 1=1'; const params: any[] = []; let paramIndex = 1; if (filters.tipo) { whereClause += ` AND tipo = $${paramIndex++}`; params.push(filters.tipo); } if (filters.estado) { whereClause += ` AND estado = $${paramIndex++}`; params.push(filters.estado); } if (filters.fechaInicio) { whereClause += ` AND fecha_emision >= $${paramIndex++}::date`; params.push(filters.fechaInicio); } if (filters.fechaFin) { whereClause += ` AND fecha_emision <= ($${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_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`; params.push(`%${filters.search}%`); } // Combinar COUNT con la query principal usando window function params.push(limit, offset); const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(` SELECT id, uuid_fiscal as "uuidFiscal", tipo, serie, folio, fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", subtotal, descuento, iva, isr_retenido as "isrRetenido", iva_retenido as "ivaRetenido", total, moneda, tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", forma_pago as "formaPago", uso_cfdi as "usoCfdi", estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", created_at as "createdAt", COUNT(*) OVER() as total_count FROM "${schema}".cfdis ${whereClause} ORDER BY fecha_emision DESC LIMIT $${paramIndex++} OFFSET $${paramIndex} `, ...params); const total = Number(dataWithCount[0]?.total_count || 0); const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[]; return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } export async function getCfdiById(schema: string, id: string): Promise { const result = await prisma.$queryRawUnsafe(` SELECT id, uuid_fiscal as "uuidFiscal", tipo, serie, folio, fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", subtotal, descuento, iva, isr_retenido as "isrRetenido", iva_retenido as "ivaRetenido", total, moneda, tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", forma_pago as "formaPago", uso_cfdi as "usoCfdi", estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", xml_original as "xmlOriginal", created_at as "createdAt" FROM "${schema}".cfdis WHERE id = $1::uuid `, id); return result[0] || null; } export async function getXmlById(schema: string, id: string): Promise { const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(` SELECT xml_original FROM "${schema}".cfdis WHERE id = $1::uuid `, id); return result[0]?.xml_original || null; } export interface CreateCfdiData { uuidFiscal: string; tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago'; serie?: string; folio?: string; fechaEmision: string; fechaTimbrado: string; rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string; subtotal: number; descuento?: number; iva?: number; isrRetenido?: number; ivaRetenido?: number; total: number; moneda?: string; tipoCambio?: number; metodoPago?: string; formaPago?: string; usoCfdi?: string; estado?: string; xmlUrl?: string; pdfUrl?: string; } export async function createCfdi(schema: string, data: CreateCfdiData): Promise { // Validate required fields if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido'); if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida'); if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido'); if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido'); // Parse dates safely - handle YYYY-MM-DD format explicitly let fechaEmision: Date; let fechaTimbrado: Date; // If date is in YYYY-MM-DD format, add time to avoid timezone issues const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/) ? `${data.fechaEmision}T12:00:00` : data.fechaEmision; fechaEmision = new Date(dateStr); if (isNaN(fechaEmision.getTime())) { throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`); } const timbradoStr = data.fechaTimbrado ? (typeof data.fechaTimbrado === 'string' && data.fechaTimbrado.match(/^\d{4}-\d{2}-\d{2}$/) ? `${data.fechaTimbrado}T12:00:00` : data.fechaTimbrado) : null; fechaTimbrado = timbradoStr ? new Date(timbradoStr) : fechaEmision; if (isNaN(fechaTimbrado.getTime())) { throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`); } const result = await prisma.$queryRawUnsafe(` 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) RETURNING id, uuid_fiscal as "uuidFiscal", tipo, serie, folio, fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor", subtotal, descuento, iva, isr_retenido as "isrRetenido", iva_retenido as "ivaRetenido", total, moneda, tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", forma_pago as "formaPago", uso_cfdi as "usoCfdi", estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", created_at as "createdAt" `, data.uuidFiscal, data.tipo || 'ingreso', data.serie || null, data.folio || null, fechaEmision, fechaTimbrado, data.rfcEmisor, data.nombreEmisor || 'Sin nombre', data.rfcReceptor, data.nombreReceptor || 'Sin nombre', data.subtotal || 0, data.descuento || 0, data.iva || 0, data.isrRetenido || 0, data.ivaRetenido || 0, data.total || 0, data.moneda || 'MXN', data.tipoCambio || 1, data.metodoPago || null, data.formaPago || null, data.usoCfdi || null, data.estado || 'vigente', data.xmlUrl || null, data.pdfUrl || null ); return result[0]; } 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); 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 { 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 { await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id); } export async function getEmisores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> { const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(` SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre FROM "${schema}".cfdis WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1 ORDER BY nombre_emisor LIMIT $2 `, `%${search}%`, limit); return result; } export async function getReceptores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> { const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(` SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre FROM "${schema}".cfdis WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1 ORDER BY nombre_receptor LIMIT $2 `, `%${search}%`, limit); return result; } export async function getResumenCfdis(schema: string, año: number, mes: number) { const result = await prisma.$queryRawUnsafe<[{ total_ingresos: number; total_egresos: number; count_ingresos: number; count_egresos: number; iva_trasladado: number; iva_acreditable: number; }]>(` SELECT COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos, COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos, COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as count_ingresos, COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos, COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado, COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable FROM "${schema}".cfdis WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1 AND EXTRACT(MONTH FROM fecha_emision) = $2 `, año, mes); const r = result[0]; return { totalIngresos: Number(r?.total_ingresos || 0), totalEgresos: Number(r?.total_egresos || 0), countIngresos: Number(r?.count_ingresos || 0), countEgresos: Number(r?.count_egresos || 0), ivaTrasladado: Number(r?.iva_trasladado || 0), ivaAcreditable: Number(r?.iva_acreditable || 0), }; }