700 lines
20 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// ==================== 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 };
|
|
}
|
|
}
|