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:
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal file
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
backend/app/Services/Parsers/DetectorFormato.php
Normal file
47
backend/app/Services/Parsers/DetectorFormato.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
180
backend/app/Services/Parsers/GenericoParser.php
Normal file
180
backend/app/Services/Parsers/GenericoParser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
40
backend/app/Services/Parsers/ParserInterface.php
Normal file
40
backend/app/Services/Parsers/ParserInterface.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user