/** * ERPIntegrationService - Servicio de Integración con ERPs * * Exportación de datos a SAP, CONTPAQi y otros sistemas. * * @module Finance */ import { DataSource, Repository, IsNull, Between } from 'typeorm'; import { ChartOfAccounts, AccountingEntry, AccountingEntryLine, AccountPayable, AccountReceivable, BankMovement, } from '../entities'; interface ServiceContext { tenantId: string; userId?: string; } interface ExportConfig { format: 'csv' | 'xml' | 'json' | 'txt'; encoding?: string; delimiter?: string; includeHeaders?: boolean; } interface SAPExportEntry { BUKRS: string; // Sociedad BELNR: string; // Número de documento GJAHR: number; // Ejercicio BLART: string; // Clase de documento BLDAT: string; // Fecha de documento BUDAT: string; // Fecha de contabilización MONAT: number; // Periodo WAERS: string; // Moneda KURSF: number; // Tipo de cambio BKTXT: string; // Texto de cabecera lines: SAPExportLine[]; } interface SAPExportLine { BUZEI: number; // Posición BSCHL: string; // Clave de contabilización HKONT: string; // Cuenta WRBTR: number; // Importe en moneda del documento DMBTR: number; // Importe en moneda local SGTXT: string; // Texto ZUONR?: string; // Asignación KOSTL?: string; // Centro de costo PROJK?: string; // Elemento PEP } interface CONTPAQiPoliza { Tipo: number; Folio: number; Fecha: string; Concepto: string; Diario: number; Movimientos: CONTPAQiMovimiento[]; } interface CONTPAQiMovimiento { NumMovto: number; Cuenta: string; Concepto: string; Cargo: number; Abono: number; Referencia?: string; Diario?: number; } interface ExportResult { success: boolean; format: string; recordCount: number; data: string | object; filename: string; errors?: string[]; } export class ERPIntegrationService { private accountRepository: Repository; private entryRepository: Repository; private lineRepository: Repository; private apRepository: Repository; private arRepository: Repository; private movementRepository: Repository; constructor(private dataSource: DataSource) { this.accountRepository = dataSource.getRepository(ChartOfAccounts); this.entryRepository = dataSource.getRepository(AccountingEntry); this.lineRepository = dataSource.getRepository(AccountingEntryLine); this.apRepository = dataSource.getRepository(AccountPayable); this.arRepository = dataSource.getRepository(AccountReceivable); this.movementRepository = dataSource.getRepository(BankMovement); } // ==================== EXPORTACIÓN SAP ==================== async exportToSAP( ctx: ServiceContext, periodStart: Date, periodEnd: Date, options: { companyCode?: string; documentType?: string; journalNumber?: number; } = {} ): Promise { const { companyCode = '1000', documentType = 'SA', journalNumber = 1 } = options; const entries = await this.entryRepository.find({ where: { tenantId: ctx.tenantId, status: 'posted', entryDate: Between(periodStart, periodEnd), deletedAt: IsNull(), }, relations: ['lines'], order: { entryDate: 'ASC', entryNumber: 'ASC' }, }); const sapEntries: SAPExportEntry[] = entries.map((entry) => ({ BUKRS: companyCode, BELNR: entry.entryNumber.replace(/[^0-9]/g, '').slice(-10).padStart(10, '0'), GJAHR: entry.fiscalYear, BLART: this.mapEntryTypeToSAP(entry.entryType), BLDAT: this.formatDateSAP(entry.entryDate), BUDAT: this.formatDateSAP(entry.entryDate), MONAT: entry.fiscalPeriod, WAERS: entry.currencyCode, KURSF: entry.exchangeRate, BKTXT: entry.description.slice(0, 25), lines: (entry.lines || []).map((line, idx) => ({ BUZEI: idx + 1, BSCHL: line.debitAmount > 0 ? '40' : '50', // 40=Debe, 50=Haber HKONT: line.accountCode.replace(/\./g, '').padStart(10, '0'), WRBTR: Math.abs(line.debitAmount || line.creditAmount), DMBTR: Math.abs(line.debitAmount || line.creditAmount) * entry.exchangeRate, SGTXT: (line.description || entry.description).slice(0, 50), ZUONR: line.reference?.slice(0, 18), KOSTL: line.costCenterId?.slice(0, 10), PROJK: line.projectId?.slice(0, 24), })), })); // Generar archivo texto para LSMW o BAPI const lines: string[] = []; for (const entry of sapEntries) { // Cabecera lines.push( `H|${entry.BUKRS}|${entry.BELNR}|${entry.GJAHR}|${entry.BLART}|${entry.BLDAT}|${entry.BUDAT}|${entry.MONAT}|${entry.WAERS}|${entry.KURSF}|${entry.BKTXT}` ); // Posiciones for (const line of entry.lines) { lines.push( `L|${line.BUZEI}|${line.BSCHL}|${line.HKONT}|${line.WRBTR}|${line.DMBTR}|${line.SGTXT}|${line.ZUONR || ''}|${line.KOSTL || ''}|${line.PROJK || ''}` ); } } const filename = `SAP_POLIZAS_${periodStart.toISOString().split('T')[0]}_${periodEnd.toISOString().split('T')[0]}.txt`; return { success: true, format: 'SAP-LSMW', recordCount: entries.length, data: lines.join('\n'), filename, }; } private mapEntryTypeToSAP(entryType: string): string { const mapping: Record = { purchase: 'KR', // Factura de acreedor sale: 'DR', // Factura de deudor payment: 'KZ', // Pago a acreedor collection: 'DZ', // Cobro de deudor payroll: 'PR', // Nómina adjustment: 'SA', // Documento contable depreciation: 'AF', // Amortización transfer: 'SA', // Traspaso opening: 'AB', // Apertura closing: 'SB', // Cierre }; return mapping[entryType] || 'SA'; } private formatDateSAP(date: Date): string { const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}${month}${day}`; } // ==================== EXPORTACIÓN CONTPAQi ==================== async exportToCONTPAQi( ctx: ServiceContext, periodStart: Date, periodEnd: Date, options: { polizaTipo?: number; diario?: number; } = {} ): Promise { const { polizaTipo = 1, diario = 1 } = options; // 1=Diario const entries = await this.entryRepository.find({ where: { tenantId: ctx.tenantId, status: 'posted', entryDate: Between(periodStart, periodEnd), deletedAt: IsNull(), }, relations: ['lines'], order: { entryDate: 'ASC', entryNumber: 'ASC' }, }); const polizas: CONTPAQiPoliza[] = entries.map((entry, idx) => ({ Tipo: this.mapEntryTypeToCONTPAQi(entry.entryType), Folio: idx + 1, Fecha: this.formatDateCONTPAQi(entry.entryDate), Concepto: entry.description.slice(0, 200), Diario: diario, Movimientos: (entry.lines || []).map((line, lineIdx) => ({ NumMovto: lineIdx + 1, Cuenta: line.accountCode, Concepto: (line.description || entry.description).slice(0, 200), Cargo: line.debitAmount, Abono: line.creditAmount, Referencia: line.reference?.slice(0, 20), Diario: diario, })), })); // Formato texto para importación CONTPAQi const lines: string[] = []; for (const poliza of polizas) { // Cabecera de póliza lines.push(`P,${poliza.Tipo},${poliza.Folio},${poliza.Fecha},"${poliza.Concepto}",${poliza.Diario}`); // Movimientos for (const mov of poliza.Movimientos) { lines.push( `M,${mov.NumMovto},"${mov.Cuenta}","${mov.Concepto}",${mov.Cargo.toFixed(2)},${mov.Abono.toFixed(2)},"${mov.Referencia || ''}"` ); } } const filename = `CONTPAQI_POLIZAS_${periodStart.toISOString().split('T')[0]}_${periodEnd.toISOString().split('T')[0]}.txt`; return { success: true, format: 'CONTPAQi-TXT', recordCount: entries.length, data: lines.join('\n'), filename, }; } private mapEntryTypeToCONTPAQi(entryType: string): number { const mapping: Record = { purchase: 2, // Egresos sale: 1, // Ingresos payment: 2, // Egresos collection: 1, // Ingresos payroll: 3, // Nómina adjustment: 4, // Diario depreciation: 4, // Diario transfer: 4, // Diario opening: 4, // Diario closing: 4, // Diario }; return mapping[entryType] || 4; } private formatDateCONTPAQi(date: Date): string { const d = new Date(date); const day = String(d.getDate()).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0'); const year = d.getFullYear(); return `${day}/${month}/${year}`; } // ==================== EXPORTACIÓN XML CFDI ==================== async exportCFDIPolizas( ctx: ServiceContext, periodStart: Date, periodEnd: Date, options: { tipoSolicitud?: string; numOrden?: string; numTramite?: string; } = {} ): Promise { const { tipoSolicitud = 'AF', numOrden, numTramite } = options; const entries = await this.entryRepository.find({ where: { tenantId: ctx.tenantId, status: 'posted', entryDate: Between(periodStart, periodEnd), deletedAt: IsNull(), }, relations: ['lines'], order: { entryDate: 'ASC' }, }); // Generar XML según Anexo 24 del SAT const xml = ` ${entries.map((entry) => this.generatePolizaXML(entry)).join('\n')} `; const filename = `CFDI_POLIZAS_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.xml`; return { success: true, format: 'CFDI-XML', recordCount: entries.length, data: xml, filename, }; } private generatePolizaXML(entry: AccountingEntry): string { const fecha = new Date(entry.entryDate).toISOString().split('T')[0]; const tipoPoliza = this.mapEntryTypeToCFDI(entry.entryType); const transacciones = (entry.lines || []) .map((line) => { return ` `; }) .join('\n'); return ` ${transacciones} `; } private mapEntryTypeToCFDI(entryType: string): string { const mapping: Record = { purchase: 'Eg', sale: 'In', payment: 'Eg', collection: 'In', payroll: 'No', adjustment: 'Di', depreciation: 'Di', transfer: 'Di', opening: 'Di', closing: 'Di', }; return mapping[entryType] || 'Di'; } private escapeXML(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ==================== EXPORTACIÓN CATÁLOGO DE CUENTAS ==================== async exportChartOfAccounts( ctx: ServiceContext, format: 'csv' | 'xml' | 'json' = 'csv' ): Promise { const accounts = await this.accountRepository.find({ where: { tenantId: ctx.tenantId, deletedAt: IsNull(), }, order: { code: 'ASC' }, }); let data: string | object; let filename: string; switch (format) { case 'xml': data = this.generateChartXML(accounts); filename = `CATALOGO_CUENTAS.xml`; break; case 'json': data = accounts.map((acc) => ({ code: acc.code, name: acc.name, type: acc.accountType, nature: acc.nature, level: acc.level, parentCode: acc.parentId, satCode: acc.satCode, status: acc.status, })); filename = `CATALOGO_CUENTAS.json`; break; default: const rows = [ 'Código,Nombre,Tipo,Naturaleza,Nivel,Código SAT,Estado', ...accounts.map( (acc) => `"${acc.code}","${acc.name}","${acc.accountType}","${acc.nature}",${acc.level},"${acc.satCode || ''}","${acc.status}"` ), ]; data = rows.join('\n'); filename = `CATALOGO_CUENTAS.csv`; } return { success: true, format, recordCount: accounts.length, data, filename, }; } private generateChartXML(accounts: ChartOfAccounts[]): string { const cuentas = accounts .map( (acc) => ` ` ) .join('\n'); return ` ${cuentas} `; } // ==================== EXPORTACIÓN BALANZA ==================== async exportTrialBalance( ctx: ServiceContext, periodStart: Date, periodEnd: Date, format: 'csv' | 'xml' | 'json' = 'csv' ): Promise { // Obtener balanza const accounts = await this.accountRepository.find({ where: { tenantId: ctx.tenantId, deletedAt: IsNull(), acceptsMovements: true, }, order: { code: 'ASC' }, }); // Obtener saldos const balances = await this.lineRepository .createQueryBuilder('line') .innerJoin('line.entry', 'entry') .select([ 'line.accountCode as "accountCode"', 'SUM(line.debitAmount) as "debit"', 'SUM(line.creditAmount) as "credit"', ]) .where('entry.tenantId = :tenantId', { tenantId: ctx.tenantId }) .andWhere('entry.status = :status', { status: 'posted' }) .andWhere('entry.entryDate BETWEEN :periodStart AND :periodEnd', { periodStart, periodEnd, }) .groupBy('line.accountCode') .getRawMany(); const balanceMap = new Map( balances.map((b) => [ b.accountCode, { debit: parseFloat(b.debit) || 0, credit: parseFloat(b.credit) || 0, }, ]) ); const rows = accounts .map((acc) => { const bal = balanceMap.get(acc.code) || { debit: 0, credit: 0 }; return { code: acc.code, name: acc.name, initialDebit: 0, initialCredit: 0, periodDebit: bal.debit, periodCredit: bal.credit, finalDebit: bal.debit, finalCredit: bal.credit, }; }) .filter((r) => r.periodDebit > 0 || r.periodCredit > 0); let data: string | object; let filename: string; switch (format) { case 'xml': data = this.generateBalanzaXML(rows, periodStart); filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.xml`; break; case 'json': data = rows; filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.json`; break; default: const csvRows = [ 'Cuenta,Nombre,Saldo Inicial Debe,Saldo Inicial Haber,Debe,Haber,Saldo Final Debe,Saldo Final Haber', ...rows.map( (r) => `"${r.code}","${r.name}",${r.initialDebit.toFixed(2)},${r.initialCredit.toFixed(2)},${r.periodDebit.toFixed(2)},${r.periodCredit.toFixed(2)},${r.finalDebit.toFixed(2)},${r.finalCredit.toFixed(2)}` ), ]; data = csvRows.join('\n'); filename = `BALANZA_${periodStart.getFullYear()}${String(periodStart.getMonth() + 1).padStart(2, '0')}.csv`; } return { success: true, format, recordCount: rows.length, data, filename, }; } private generateBalanzaXML( rows: { code: string; name: string; initialDebit: number; initialCredit: number; periodDebit: number; periodCredit: number; finalDebit: number; finalCredit: number; }[], periodStart: Date ): string { const cuentas = rows .map( (r) => ` ` ) .join('\n'); return ` ${cuentas} `; } // ==================== IMPORTACIÓN ==================== async importChartOfAccounts( ctx: ServiceContext, data: string, format: 'csv' | 'json' ): Promise<{ imported: number; errors: string[] }> { const errors: string[] = []; let imported = 0; let accounts: { code: string; name: string; type: AccountType; nature: 'debit' | 'credit'; level: number; parentCode?: string; satCode?: string; }[] = []; if (format === 'json') { accounts = JSON.parse(data); } else { const lines = data.split('\n').slice(1); // Skip header accounts = lines .filter((line) => line.trim()) .map((line) => { const parts = line.split(',').map((p) => p.replace(/"/g, '').trim()); return { code: parts[0], name: parts[1], type: parts[2] as AccountType, nature: parts[3] as 'debit' | 'credit', level: parseInt(parts[4]) || 1, satCode: parts[5], }; }); } for (const acc of accounts) { try { const existing = await this.accountRepository.findOne({ where: { tenantId: ctx.tenantId, code: acc.code, deletedAt: IsNull(), }, }); if (existing) { // Actualizar existing.name = acc.name; existing.accountType = acc.type; existing.nature = acc.nature; existing.level = acc.level; existing.satCode = acc.satCode; existing.updatedBy = ctx.userId; await this.accountRepository.save(existing); } else { // Crear const newAccount = this.accountRepository.create({ tenantId: ctx.tenantId, code: acc.code, name: acc.name, accountType: acc.type, nature: acc.nature, level: acc.level, satCode: acc.satCode, fullPath: acc.code, isGroupAccount: false, acceptsMovements: true, status: 'active', currencyCode: 'MXN', balance: 0, createdBy: ctx.userId, }); await this.accountRepository.save(newAccount); } imported++; } catch (error) { errors.push(`Error en cuenta ${acc.code}: ${(error as Error).message}`); } } return { imported, errors }; } }