Initial commit: Horux Strategy Platform

- Laravel 11 backend with API REST
- React 18 + TypeScript + Vite frontend
- Multi-parser architecture for accounting systems (CONTPAQi, Aspel, SAP)
- 27+ financial metrics calculation
- PDF report generation with Browsershot
- Complete documentation (10 documents)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 22:24:00 -06:00
commit 4c3dc94ff2
107 changed files with 10701 additions and 0 deletions

View File

@@ -0,0 +1,419 @@
<?php
namespace App\Services;
use App\Models\Reporte;
use App\Models\Umbral;
use App\Models\CategoriaContable;
class CalculadorMetricas
{
private array $categorias;
private ?int $giroId;
public function calcular(Reporte $reporte): array
{
$this->giroId = $reporte->cliente->giro_id;
$this->cargarCategorias();
$balanzas = $reporte->balanzas()->with('cuentas')->orderBy('periodo_fin')->get();
// Calcular estados financieros para cada periodo
$periodos = [];
foreach ($balanzas as $balanza) {
$periodos[] = [
'periodo' => $balanza->periodo_fin->format('Y-m'),
'balance_general' => $this->calcularBalanceGeneral($balanza),
'estado_resultados' => $this->calcularEstadoResultados($balanza),
];
}
// Calcular métricas del último periodo
$ultimoPeriodo = end($periodos);
$metricas = $this->calcularTodasLasMetricas($ultimoPeriodo);
// Calcular comparativos si hay múltiples periodos
$comparativos = [];
if (count($periodos) >= 2) {
$comparativos = $this->calcularComparativos($periodos, $metricas);
}
// Calcular flujo de efectivo
$flujoEfectivo = $this->calcularFlujoEfectivo($periodos);
return [
'periodos' => $periodos,
'metricas' => $metricas,
'comparativos' => $comparativos,
'flujo_efectivo' => $flujoEfectivo,
'estados_financieros' => [
'balance_general' => $ultimoPeriodo['balance_general'],
'estado_resultados' => $ultimoPeriodo['estado_resultados'],
'flujo_efectivo' => $flujoEfectivo,
],
];
}
private function cargarCategorias(): void
{
$this->categorias = CategoriaContable::all()->keyBy('nombre')->toArray();
}
private function calcularBalanceGeneral($balanza): array
{
$cuentas = $balanza->cuentasActivas()
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Balance General'))
->get();
$totales = [
'activos_circulantes' => 0,
'activos_no_circulantes' => 0,
'total_activos' => 0,
'pasivo_circulante' => 0,
'pasivo_no_circulante' => 0,
'total_pasivos' => 0,
'capital_social' => 0,
'utilidades_anteriores' => 0,
'perdidas_anteriores' => 0,
'total_capital' => 0,
];
foreach ($cuentas as $cuenta) {
if (!$cuenta->categoriaContable) continue;
$saldo = $cuenta->saldo_final_neto;
$categoria = $cuenta->categoriaContable->nombre;
switch ($categoria) {
case 'Activos Circulantes':
$totales['activos_circulantes'] += $saldo;
break;
case 'Activos No Circulantes':
$totales['activos_no_circulantes'] += $saldo;
break;
case 'Pasivo Circulante':
$totales['pasivo_circulante'] += abs($saldo);
break;
case 'Pasivo No Circulante':
$totales['pasivo_no_circulante'] += abs($saldo);
break;
case 'Capital Social':
$totales['capital_social'] += abs($saldo);
break;
case 'Utilidades Ejercicios Anteriores':
$totales['utilidades_anteriores'] += abs($saldo);
break;
case 'Pérdidas Ejercicios Anteriores':
$totales['perdidas_anteriores'] += abs($saldo);
break;
}
}
$totales['total_activos'] = $totales['activos_circulantes'] + $totales['activos_no_circulantes'];
$totales['total_pasivos'] = $totales['pasivo_circulante'] + $totales['pasivo_no_circulante'];
$totales['total_capital'] = $totales['capital_social'] + $totales['utilidades_anteriores'] - $totales['perdidas_anteriores'];
return $totales;
}
private function calcularEstadoResultados($balanza): array
{
$cuentas = $balanza->cuentasActivas()
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Estado de Resultados'))
->get();
$totales = [
'ingresos' => 0,
'costo_venta' => 0,
'utilidad_bruta' => 0,
'gastos_operativos' => 0,
'utilidad_operativa' => 0,
'otros_gastos' => 0,
'gastos_financieros' => 0,
'utilidad_antes_impuestos' => 0,
'impuestos' => 0,
'utilidad_neta' => 0,
];
foreach ($cuentas as $cuenta) {
if (!$cuenta->categoriaContable) continue;
$saldo = abs($cuenta->saldo_final_neto);
$categoria = $cuenta->categoriaContable->nombre;
switch ($categoria) {
case 'Ingresos':
$totales['ingresos'] += $saldo;
break;
case 'Costo de Venta':
$totales['costo_venta'] += $saldo;
break;
case 'Gastos Operativos':
$totales['gastos_operativos'] += $saldo;
break;
case 'Otros Gastos':
$totales['otros_gastos'] += $saldo;
break;
case 'Gastos Financieros':
$totales['gastos_financieros'] += $saldo;
break;
}
}
// Calcular subtotales
$totales['utilidad_bruta'] = $totales['ingresos'] - $totales['costo_venta'];
$totales['utilidad_operativa'] = $totales['utilidad_bruta'] - $totales['gastos_operativos'];
$totales['utilidad_antes_impuestos'] = $totales['utilidad_operativa'] - $totales['otros_gastos'] - $totales['gastos_financieros'];
// Estimación de impuestos (30% para México)
$totales['impuestos'] = max(0, $totales['utilidad_antes_impuestos'] * 0.30);
$totales['utilidad_neta'] = $totales['utilidad_antes_impuestos'] - $totales['impuestos'];
return $totales;
}
private function calcularTodasLasMetricas(array $periodo): array
{
$balance = $periodo['balance_general'];
$resultados = $periodo['estado_resultados'];
$metricas = [];
// === MÁRGENES ===
$ingresos = $resultados['ingresos'] ?: 1; // Evitar división por cero
$metricas['margen_bruto'] = $this->crearMetrica(
'Margen Bruto',
$resultados['utilidad_bruta'] / $ingresos,
'margen_bruto'
);
// EBITDA = Utilidad Operativa + Depreciación (estimada como 5% de activos fijos)
$depreciacion = $balance['activos_no_circulantes'] * 0.05;
$ebitda = $resultados['utilidad_operativa'] + $depreciacion;
$metricas['margen_ebitda'] = $this->crearMetrica(
'Margen EBITDA',
$ebitda / $ingresos,
'margen_ebitda'
);
$metricas['margen_operativo'] = $this->crearMetrica(
'Margen Operativo',
$resultados['utilidad_operativa'] / $ingresos,
'margen_operativo'
);
$metricas['margen_neto'] = $this->crearMetrica(
'Margen Neto',
$resultados['utilidad_neta'] / $ingresos,
'margen_neto'
);
// NOPAT = EBIT * (1 - Tasa impuestos)
$nopat = $resultados['utilidad_operativa'] * 0.70;
$metricas['margen_nopat'] = $this->crearMetrica(
'Margen NOPAT',
$nopat / $ingresos,
'margen_nopat'
);
// === RETORNO ===
$capitalInvertido = $balance['total_activos'] - $balance['pasivo_circulante'];
$capitalEmpleado = $balance['total_activos'] - $balance['pasivo_circulante'];
$metricas['roic'] = $this->crearMetrica(
'ROIC',
$capitalInvertido > 0 ? $nopat / $capitalInvertido : 0,
'roic'
);
$metricas['roe'] = $this->crearMetrica(
'ROE',
$balance['total_capital'] > 0 ? $resultados['utilidad_neta'] / $balance['total_capital'] : 0,
'roe'
);
$metricas['roa'] = $this->crearMetrica(
'ROA',
$balance['total_activos'] > 0 ? $resultados['utilidad_neta'] / $balance['total_activos'] : 0,
'roa'
);
$metricas['roce'] = $this->crearMetrica(
'ROCE',
$capitalEmpleado > 0 ? $resultados['utilidad_operativa'] / $capitalEmpleado : 0,
'roce'
);
// === LIQUIDEZ ===
$pasivoCirculante = $balance['pasivo_circulante'] ?: 1;
$metricas['current_ratio'] = $this->crearMetrica(
'Current Ratio',
$balance['activos_circulantes'] / $pasivoCirculante,
'current_ratio'
);
// Quick ratio (estimando inventario como 30% de activos circulantes)
$inventarioEstimado = $balance['activos_circulantes'] * 0.30;
$metricas['quick_ratio'] = $this->crearMetrica(
'Quick Ratio',
($balance['activos_circulantes'] - $inventarioEstimado) / $pasivoCirculante,
'quick_ratio'
);
// Cash ratio (estimando efectivo como 15% de activos circulantes)
$efectivoEstimado = $balance['activos_circulantes'] * 0.15;
$metricas['cash_ratio'] = $this->crearMetrica(
'Cash Ratio',
$efectivoEstimado / $pasivoCirculante,
'cash_ratio'
);
// === SOLVENCIA ===
$deudaTotal = $balance['total_pasivos'];
$ebitdaAnual = $ebitda * 12; // Anualizar si es mensual
$metricas['net_debt_ebitda'] = $this->crearMetrica(
'Net Debt / EBITDA',
$ebitdaAnual > 0 ? ($deudaTotal - $efectivoEstimado) / $ebitdaAnual : 0,
'net_debt_ebitda'
);
$gastosFinancieros = $resultados['gastos_financieros'] ?: 1;
$metricas['interest_coverage'] = $this->crearMetrica(
'Interest Coverage',
$ebitda / $gastosFinancieros,
'interest_coverage'
);
$metricas['debt_ratio'] = $this->crearMetrica(
'Debt Ratio',
$balance['total_activos'] > 0 ? $deudaTotal / $balance['total_activos'] : 0,
'debt_ratio'
);
return $metricas;
}
private function crearMetrica(string $nombre, float $valor, string $codigoUmbral): array
{
$umbral = $this->obtenerUmbral($codigoUmbral);
$tendencia = $umbral ? $umbral->evaluarValor($valor) : 'neutral';
return [
'nombre' => $nombre,
'valor' => round($valor, 4),
'valor_porcentaje' => round($valor * 100, 2),
'tendencia' => $tendencia,
];
}
private function obtenerUmbral(string $metrica): ?Umbral
{
// Buscar umbral específico del giro primero
$umbral = Umbral::where('metrica', $metrica)
->where('giro_id', $this->giroId)
->first();
if (!$umbral) {
// Buscar umbral genérico
$umbral = Umbral::where('metrica', $metrica)
->whereNull('giro_id')
->first();
}
return $umbral;
}
private function calcularComparativos(array $periodos, array $metricasActuales): array
{
$comparativos = [];
if (count($periodos) < 2) {
return $comparativos;
}
$periodoActual = end($periodos);
$periodoAnterior = $periodos[count($periodos) - 2];
// Calcular métricas del periodo anterior
$metricasAnterior = $this->calcularTodasLasMetricas($periodoAnterior);
foreach ($metricasActuales as $key => $metrica) {
if (isset($metricasAnterior[$key])) {
$valorActual = $metrica['valor'];
$valorAnterior = $metricasAnterior[$key]['valor'];
$variacion = $valorAnterior != 0 ? ($valorActual - $valorAnterior) / abs($valorAnterior) : 0;
$comparativos[$key] = [
'valor_actual' => $valorActual,
'valor_anterior' => $valorAnterior,
'variacion_absoluta' => $valorActual - $valorAnterior,
'variacion_porcentual' => round($variacion * 100, 2),
];
}
}
// Promedio de 3 periodos si hay suficientes datos
if (count($periodos) >= 3) {
$ultimos3 = array_slice($periodos, -3);
foreach ($metricasActuales as $key => $metrica) {
$suma = 0;
foreach ($ultimos3 as $p) {
$m = $this->calcularTodasLasMetricas($p);
$suma += $m[$key]['valor'] ?? 0;
}
$comparativos[$key]['promedio_3_periodos'] = round($suma / 3, 4);
}
}
return $comparativos;
}
private function calcularFlujoEfectivo(array $periodos): array
{
if (count($periodos) < 2) {
return ['metodo' => 'indirecto', 'sin_datos' => true];
}
$actual = end($periodos);
$anterior = $periodos[count($periodos) - 2];
$balanceActual = $actual['balance_general'];
$balanceAnterior = $anterior['balance_general'];
$resultados = $actual['estado_resultados'];
// Método indirecto
$utilidadNeta = $resultados['utilidad_neta'];
$depreciacion = $balanceActual['activos_no_circulantes'] * 0.05;
// Cambios en capital de trabajo
$cambioActivosCirc = $balanceActual['activos_circulantes'] - $balanceAnterior['activos_circulantes'];
$cambioPasivosCirc = $balanceActual['pasivo_circulante'] - $balanceAnterior['pasivo_circulante'];
$flujoOperacion = $utilidadNeta + $depreciacion - $cambioActivosCirc + $cambioPasivosCirc;
// Flujo de inversión (cambio en activos no circulantes)
$flujoInversion = -($balanceActual['activos_no_circulantes'] - $balanceAnterior['activos_no_circulantes']);
// Flujo de financiamiento
$cambioDeuda = $balanceActual['total_pasivos'] - $balanceAnterior['total_pasivos'];
$cambioCapital = $balanceActual['total_capital'] - $balanceAnterior['total_capital'];
$flujoFinanciamiento = $cambioDeuda + $cambioCapital - $utilidadNeta;
return [
'metodo' => 'indirecto',
'flujo_operacion' => round($flujoOperacion, 2),
'flujo_inversion' => round($flujoInversion, 2),
'flujo_financiamiento' => round($flujoFinanciamiento, 2),
'flujo_neto' => round($flujoOperacion + $flujoInversion + $flujoFinanciamiento, 2),
'detalle' => [
'utilidad_neta' => $utilidadNeta,
'depreciacion' => $depreciacion,
'cambio_capital_trabajo' => -$cambioActivosCirc + $cambioPasivosCirc,
],
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Models\Balanza;
use App\Models\Cuenta;
use App\Models\ReglaMapeo;
use App\Models\MapeoCuenta;
class ClasificadorCuentas
{
/**
* Clasifica todas las cuentas de una balanza según las reglas de mapeo
*/
public function clasificar(Balanza $balanza): void
{
$cuentas = $balanza->cuentas()->get();
$sistemaOrigen = $balanza->sistema_origen;
$clienteId = $balanza->cliente_id;
// Obtener reglas ordenadas por prioridad
$reglas = ReglaMapeo::where('sistema_origen', $sistemaOrigen)
->where('activo', true)
->orderByDesc('prioridad')
->get();
// Obtener mapeos específicos del cliente
$mapeosCliente = MapeoCuenta::where('cliente_id', $clienteId)->get();
foreach ($cuentas as $cuenta) {
$this->clasificarCuenta($cuenta, $reglas, $mapeosCliente);
}
// Establecer relaciones padre-hijo en base de datos
$this->establecerRelacionesPadreHijo($balanza);
}
private function clasificarCuenta(Cuenta $cuenta, $reglas, $mapeosCliente): void
{
// Primero buscar en mapeos específicos del cliente
foreach ($mapeosCliente as $mapeo) {
if ($this->coincidePatron($cuenta->codigo, $mapeo->codigo_patron)) {
$cuenta->update([
'categoria_contable_id' => $mapeo->categoria_contable_id,
'reporte_contable_id' => $mapeo->categoriaContable->reporte_contable_id,
'requiere_revision' => false,
]);
return;
}
}
// Buscar en reglas del sistema
foreach ($reglas as $regla) {
if ($regla->coincideCon($cuenta->codigo)) {
$cuenta->update([
'categoria_contable_id' => $regla->categoria_contable_id,
'reporte_contable_id' => $regla->reporte_contable_id,
'requiere_revision' => false,
]);
return;
}
}
// No se encontró regla - marcar para revisión
$this->marcarParaRevision($cuenta);
}
private function coincidePatron(string $codigo, string $patron): bool
{
// El patrón puede ser exacto o con wildcards (*)
$regex = '/^' . str_replace(['*', '-'], ['.*', '\-'], $patron) . '$/';
return (bool) preg_match($regex, $codigo);
}
private function marcarParaRevision(Cuenta $cuenta): void
{
$nota = $this->generarNotaRevision($cuenta);
$cuenta->update([
'requiere_revision' => true,
'nota_revision' => $nota,
]);
}
private function generarNotaRevision(Cuenta $cuenta): string
{
$codigo = $cuenta->codigo;
$notas = [];
// Detectar posibles anomalías basadas en el código
if (preg_match('/^45[0-9]-/', $codigo)) {
$notas[] = 'Código 45X normalmente es pasivo pero podría ser gasto. Verificar clasificación.';
}
if (preg_match('/^[89][0-9]{2}-/', $codigo)) {
$notas[] = 'Cuenta de orden o especial. Verificar si debe incluirse en estados financieros.';
}
if (empty($notas)) {
$notas[] = 'No se encontró regla de mapeo para este código. Asignar clasificación manualmente.';
}
return implode(' ', $notas);
}
private function establecerRelacionesPadreHijo(Balanza $balanza): void
{
$cuentas = $balanza->cuentas()->get()->keyBy('codigo');
foreach ($cuentas as $cuenta) {
if (isset($cuenta->cuenta_padre_codigo)) {
$padre = $cuentas->get($cuenta->cuenta_padre_codigo);
if ($padre) {
$cuenta->update(['cuenta_padre_id' => $padre->id]);
}
}
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services;
use App\Models\Reporte;
use Spatie\Browsershot\Browsershot;
class GeneradorPdf
{
private string $frontendUrl;
public function __construct()
{
$this->frontendUrl = config('app.frontend_url', 'http://localhost:5173');
}
/**
* Genera el PDF del reporte
*
* @param Reporte $reporte
* @return string Path relativo del PDF generado
*/
public function generar(Reporte $reporte): string
{
$outputPath = 'reportes/' . $reporte->cliente_id . '/' . $reporte->id . '.pdf';
$fullPath = storage_path('app/' . $outputPath);
// Crear directorio si no existe
$dir = dirname($fullPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// URL del frontend para renderizar el reporte
$url = $this->frontendUrl . '/pdf-view/' . $reporte->id . '?token=' . $this->generarTokenTemporal($reporte);
Browsershot::url($url)
->waitUntilNetworkIdle()
->format('Letter')
->margins(0, 0, 0, 0)
->showBackground()
->save($fullPath);
return $outputPath;
}
/**
* Genera un token temporal para acceso al PDF
*/
private function generarTokenTemporal(Reporte $reporte): string
{
// Token válido por 5 minutos
$data = [
'reporte_id' => $reporte->id,
'expires' => time() + 300,
];
return base64_encode(json_encode($data));
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Services\Parsers;
use Spatie\PdfToText\Pdf;
class ContpaqiParser implements ParserInterface
{
public function getSistema(): string
{
return 'contpaqi';
}
public function puedeManej(string $filePath): bool
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if ($extension !== 'pdf') {
return false;
}
try {
$text = Pdf::getText($filePath);
// Buscar patrones característicos de CONTPAQi
$patronesContpaqi = [
'/CONTPAQ/i',
'/Balanza de Comprobaci[óo]n/i',
'/\d{3}-\d{3}-\d{3}/', // Patrón de código de cuenta CONTPAQi
'/Saldo\s+Inicial.*Debe.*Haber.*Saldo\s+Final/is',
];
$coincidencias = 0;
foreach ($patronesContpaqi as $patron) {
if (preg_match($patron, $text)) {
$coincidencias++;
}
}
return $coincidencias >= 2;
} catch (\Exception $e) {
return false;
}
}
public function parsear(string $filePath): array
{
$text = Pdf::getText($filePath);
$lineas = explode("\n", $text);
$cuentas = [];
foreach ($lineas as $linea) {
$cuenta = $this->parsearLinea($linea);
if ($cuenta) {
$cuentas[] = $cuenta;
}
}
// Establecer relaciones padre-hijo
$this->establecerJerarquia($cuentas);
return $cuentas;
}
private function parsearLinea(string $linea): ?array
{
// Patrón para líneas de cuenta CONTPAQi
// Formato típico: "001-100-000 ACTIVO CIRCULANTE 1,234.56 0.00 500.00 200.00 1,534.56 0.00"
$patron = '/^(\d{3}-\d{3}-\d{3})\s+(.+?)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)$/';
if (!preg_match($patron, trim($linea), $matches)) {
return null;
}
$codigo = $matches[1];
$nombre = trim($matches[2]);
// Determinar nivel basado en el código
$nivel = $this->determinarNivel($codigo);
// Determinar si es cuenta padre (termina en -000-000 o -XXX-000)
$esCuentaPadre = preg_match('/-000-000$/', $codigo) || preg_match('/-\d{3}-000$/', $codigo);
return [
'codigo' => $codigo,
'nombre' => $nombre,
'nivel' => $nivel,
'saldo_inicial_deudor' => $this->parsearNumero($matches[3]),
'saldo_inicial_acreedor' => $this->parsearNumero($matches[4]),
'cargos' => $this->parsearNumero($matches[5]),
'abonos' => $this->parsearNumero($matches[6]),
'saldo_final_deudor' => $this->parsearNumero($matches[7]),
'saldo_final_acreedor' => $this->parsearNumero($matches[8]),
'es_cuenta_padre' => $esCuentaPadre,
];
}
private function determinarNivel(string $codigo): int
{
// En CONTPAQi el nivel se puede inferir del patrón de código
// XXX-000-000 = Nivel 1 (cuenta mayor)
// XXX-XXX-000 = Nivel 2 (subcuenta)
// XXX-XXX-XXX = Nivel 3 (detalle)
if (preg_match('/-000-000$/', $codigo)) {
return 1;
}
if (preg_match('/-\d{3}-000$/', $codigo)) {
return 2;
}
return 3;
}
private function parsearNumero(string $valor): float
{
// Remover comas y convertir a float
return (float) str_replace(',', '', $valor);
}
private function establecerJerarquia(array &$cuentas): void
{
// Crear índice por código
$indice = [];
foreach ($cuentas as $i => $cuenta) {
$indice[$cuenta['codigo']] = $i;
}
// Establecer padres basado en el código
foreach ($cuentas as $i => &$cuenta) {
$codigo = $cuenta['codigo'];
$partes = explode('-', $codigo);
// Buscar cuenta padre
if ($partes[2] !== '000') {
// Buscar padre de nivel 2 (XXX-XXX-000)
$codigoPadre = $partes[0] . '-' . $partes[1] . '-000';
if (isset($indice[$codigoPadre])) {
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
}
} elseif ($partes[1] !== '000') {
// Buscar padre de nivel 1 (XXX-000-000)
$codigoPadre = $partes[0] . '-000-000';
if (isset($indice[$codigoPadre])) {
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services\Parsers;
class DetectorFormato
{
private array $parsers;
public function __construct()
{
$this->parsers = [
new ContpaqiParser(),
// Agregar más parsers aquí: AspelParser, SapParser, etc.
new GenericoParser(), // Debe ser el último como fallback
];
}
/**
* Detecta el formato del archivo y retorna el parser apropiado
*
* @param string $filePath Ruta completa al archivo
* @return array{sistema: string, parser: ParserInterface}
* @throws \Exception Si no se puede detectar el formato
*/
public function detectar(string $filePath): array
{
foreach ($this->parsers as $parser) {
if ($parser->puedeManej($filePath)) {
return [
'sistema' => $parser->getSistema(),
'parser' => $parser,
];
}
}
throw new \Exception('No se pudo detectar el formato del archivo');
}
/**
* Registra un parser adicional
*/
public function registrarParser(ParserInterface $parser): void
{
// Insertar antes del GenericoParser
array_splice($this->parsers, -1, 0, [$parser]);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services\Parsers;
use PhpOffice\PhpSpreadsheet\IOFactory;
class GenericoParser implements ParserInterface
{
private array $mapeoColumnas = [
'codigo' => ['codigo', 'cuenta', 'code', 'account', 'cta', 'numero'],
'nombre' => ['nombre', 'descripcion', 'name', 'description', 'concepto'],
'saldo_inicial_deudor' => ['saldo_inicial_deudor', 'inicial_debe', 'opening_debit', 'si_deudor'],
'saldo_inicial_acreedor' => ['saldo_inicial_acreedor', 'inicial_haber', 'opening_credit', 'si_acreedor'],
'cargos' => ['cargos', 'debe', 'debit', 'debits', 'movs_deudor'],
'abonos' => ['abonos', 'haber', 'credit', 'credits', 'movs_acreedor'],
'saldo_final_deudor' => ['saldo_final_deudor', 'final_debe', 'closing_debit', 'sf_deudor'],
'saldo_final_acreedor' => ['saldo_final_acreedor', 'final_haber', 'closing_credit', 'sf_acreedor'],
];
public function getSistema(): string
{
return 'generico';
}
public function puedeManej(string $filePath): bool
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
return in_array($extension, ['xlsx', 'xls', 'csv']);
}
public function parsear(string $filePath): array
{
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if ($extension === 'csv') {
return $this->parsearCsv($filePath);
}
return $this->parsearExcel($filePath);
}
private function parsearExcel(string $filePath): array
{
$spreadsheet = IOFactory::load($filePath);
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray();
if (empty($rows)) {
return [];
}
// Primera fila son headers
$headers = array_map(fn($h) => $this->normalizarHeader($h), $rows[0]);
$mapeo = $this->mapearColumnas($headers);
$cuentas = [];
for ($i = 1; $i < count($rows); $i++) {
$cuenta = $this->parsearFila($rows[$i], $mapeo);
if ($cuenta) {
$cuentas[] = $cuenta;
}
}
return $cuentas;
}
private function parsearCsv(string $filePath): array
{
$handle = fopen($filePath, 'r');
if (!$handle) {
throw new \Exception('No se pudo abrir el archivo CSV');
}
// Detectar delimitador
$primeraLinea = fgets($handle);
rewind($handle);
$delimitador = $this->detectarDelimitador($primeraLinea);
// Primera fila son headers
$headers = fgetcsv($handle, 0, $delimitador);
$headers = array_map(fn($h) => $this->normalizarHeader($h), $headers);
$mapeo = $this->mapearColumnas($headers);
$cuentas = [];
while (($row = fgetcsv($handle, 0, $delimitador)) !== false) {
$cuenta = $this->parsearFila($row, $mapeo);
if ($cuenta) {
$cuentas[] = $cuenta;
}
}
fclose($handle);
return $cuentas;
}
private function normalizarHeader(?string $header): string
{
if ($header === null) {
return '';
}
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]/', '_', $header)));
}
private function detectarDelimitador(string $linea): string
{
$delimitadores = [',', ';', "\t", '|'];
$conteos = [];
foreach ($delimitadores as $d) {
$conteos[$d] = substr_count($linea, $d);
}
return array_keys($conteos, max($conteos))[0];
}
private function mapearColumnas(array $headers): array
{
$mapeo = [];
foreach ($this->mapeoColumnas as $campo => $aliases) {
foreach ($headers as $index => $header) {
if (in_array($header, $aliases)) {
$mapeo[$campo] = $index;
break;
}
}
}
return $mapeo;
}
private function parsearFila(array $row, array $mapeo): ?array
{
// Verificar que tenemos código y nombre
if (!isset($mapeo['codigo']) || !isset($mapeo['nombre'])) {
return null;
}
$codigo = trim($row[$mapeo['codigo']] ?? '');
$nombre = trim($row[$mapeo['nombre']] ?? '');
if (empty($codigo) || empty($nombre)) {
return null;
}
// Determinar nivel basado en la estructura del código
$nivel = $this->determinarNivel($codigo);
$esCuentaPadre = $nivel <= 2;
return [
'codigo' => $codigo,
'nombre' => $nombre,
'nivel' => $nivel,
'saldo_inicial_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_deudor'),
'saldo_inicial_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_acreedor'),
'cargos' => $this->obtenerNumero($row, $mapeo, 'cargos'),
'abonos' => $this->obtenerNumero($row, $mapeo, 'abonos'),
'saldo_final_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_deudor'),
'saldo_final_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_acreedor'),
'es_cuenta_padre' => $esCuentaPadre,
];
}
private function determinarNivel(string $codigo): int
{
// Contar separadores para determinar nivel
$separadores = preg_match_all('/[-.\s]/', $codigo);
return min(3, $separadores + 1);
}
private function obtenerNumero(array $row, array $mapeo, string $campo): float
{
if (!isset($mapeo[$campo])) {
return 0.0;
}
$valor = $row[$mapeo[$campo]] ?? 0;
return (float) str_replace([',', '$', ' '], '', $valor);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Parsers;
interface ParserInterface
{
/**
* Parsea un archivo de balanza y retorna array de cuentas normalizadas
*
* @param string $filePath Ruta completa al archivo
* @return array<int, array{
* codigo: string,
* nombre: string,
* nivel: int,
* saldo_inicial_deudor: float,
* saldo_inicial_acreedor: float,
* cargos: float,
* abonos: float,
* saldo_final_deudor: float,
* saldo_final_acreedor: float,
* es_cuenta_padre: bool
* }>
*/
public function parsear(string $filePath): array;
/**
* Verifica si este parser puede manejar el archivo
*
* @param string $filePath Ruta completa al archivo
* @return bool
*/
public function puedeManej(string $filePath): bool;
/**
* Retorna el identificador del sistema
*
* @return string
*/
public function getSistema(): string;
}