erp-construccion-backend-v2/src/modules/finance/services/erp-integration.service.ts
rckrdmrd 7c1480a819 Migración desde erp-construccion/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:14 -06:00

700 lines
20 KiB
TypeScript

/**
* 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<ChartOfAccounts>;
private entryRepository: Repository<AccountingEntry>;
private lineRepository: Repository<AccountingEntryLine>;
private apRepository: Repository<AccountPayable>;
private arRepository: Repository<AccountReceivable>;
private movementRepository: Repository<BankMovement>;
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<ExportResult> {
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<string, string> = {
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<ExportResult> {
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<string, number> = {
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<ExportResult> {
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 = `<?xml version="1.0" encoding="UTF-8"?>
<PLZ:Polizas
xmlns:PLZ="http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/PolizasPeriodo"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/PolizasPeriodo http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/PolizasPeriodo/PolizasPeriodo_1_3.xsd"
Version="1.3"
TipoSolicitud="${tipoSolicitud}"
${numOrden ? `NumOrden="${numOrden}"` : ''}
${numTramite ? `NumTramite="${numTramite}"` : ''}
Anio="${periodStart.getFullYear()}"
Mes="${String(periodStart.getMonth() + 1).padStart(2, '0')}"
>
${entries.map((entry) => this.generatePolizaXML(entry)).join('\n')}
</PLZ:Polizas>`;
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 ` <PLZ:Transaccion
NumCta="${this.escapeXML(line.accountCode)}"
DesCta="${this.escapeXML(line.description || '')}"
Concepto="${this.escapeXML(entry.description)}"
Debe="${line.debitAmount.toFixed(2)}"
Haber="${line.creditAmount.toFixed(2)}"
/>`;
})
.join('\n');
return ` <PLZ:Poliza
NumUnIdenPol="${this.escapeXML(entry.entryNumber)}"
Fecha="${fecha}"
Concepto="${this.escapeXML(entry.description)}"
>
${transacciones}
</PLZ:Poliza>`;
}
private mapEntryTypeToCFDI(entryType: string): string {
const mapping: Record<string, string> = {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// ==================== EXPORTACIÓN CATÁLOGO DE CUENTAS ====================
async exportChartOfAccounts(
ctx: ServiceContext,
format: 'csv' | 'xml' | 'json' = 'csv'
): Promise<ExportResult> {
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) => ` <Cuenta
CodAgrup="${acc.satCode || ''}"
NumCta="${acc.code}"
Desc="${this.escapeXML(acc.name)}"
Nivel="${acc.level}"
Natur="${acc.nature === 'debit' ? 'D' : 'A'}"
/>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<BCE:Catalogo
xmlns:BCE="http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/CatalogoCuentas"
Version="1.3"
Anio="${new Date().getFullYear()}"
Mes="${String(new Date().getMonth() + 1).padStart(2, '0')}"
>
${cuentas}
</BCE:Catalogo>`;
}
// ==================== EXPORTACIÓN BALANZA ====================
async exportTrialBalance(
ctx: ServiceContext,
periodStart: Date,
periodEnd: Date,
format: 'csv' | 'xml' | 'json' = 'csv'
): Promise<ExportResult> {
// 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) => ` <BCE:Ctas
NumCta="${r.code}"
SaldoIni="${(r.initialDebit - r.initialCredit).toFixed(2)}"
Debe="${r.periodDebit.toFixed(2)}"
Haber="${r.periodCredit.toFixed(2)}"
SaldoFin="${(r.finalDebit - r.finalCredit).toFixed(2)}"
/>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<BCE:Balanza
xmlns:BCE="http://www.sat.gob.mx/esquemas/ContabilidadE/1_3/BalanzaComprobacion"
Version="1.3"
Anio="${periodStart.getFullYear()}"
Mes="${String(periodStart.getMonth() + 1).padStart(2, '0')}"
TipoEnvio="N"
>
${cuentas}
</BCE:Balanza>`;
}
// ==================== 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 };
}
}