Database optimizations: - Add indexes on fecha_emision, tipo, estado, rfc_emisor, rfc_receptor - Add trigram indexes for fast ILIKE searches on nombre fields - Combine COUNT with main query using window function (1 query instead of 2) Frontend optimizations: - Add 300ms debounce to autocomplete searches - Add staleTime (30s) and gcTime (5min) to useCfdis hook - Reduce unnecessary API calls on every keystroke Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
import { prisma } from '../config/database.js';
|
|
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
|
|
|
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
|
|
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<Cfdi | null> {
|
|
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
|
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<string | null> {
|
|
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<Cfdi> {
|
|
// 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<Cfdi[]>(`
|
|
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<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 {
|
|
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> {
|
|
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),
|
|
};
|
|
}
|