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,62 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Giro;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GiroController extends Controller
{
public function index(): JsonResponse
{
return response()->json(Giro::all());
}
public function activos(): JsonResponse
{
return response()->json(Giro::where('activo', true)->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'nombre' => 'required|string|max:255|unique:giros,nombre',
'activo' => 'boolean',
]);
$giro = Giro::create($validated);
return response()->json($giro, 201);
}
public function show(Giro $giro): JsonResponse
{
return response()->json($giro);
}
public function update(Request $request, Giro $giro): JsonResponse
{
$validated = $request->validate([
'nombre' => 'string|max:255|unique:giros,nombre,' . $giro->id,
'activo' => 'boolean',
]);
$giro->update($validated);
return response()->json($giro);
}
public function destroy(Giro $giro): JsonResponse
{
if ($giro->clientes()->exists()) {
return response()->json([
'message' => 'No se puede eliminar un giro con clientes asociados'
], 422);
}
$giro->delete();
return response()->json(['message' => 'Giro eliminado']);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ReglaMapeo;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReglaMapeeoController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = ReglaMapeo::with(['reporteContable', 'categoriaContable']);
if ($request->has('sistema_origen')) {
$query->where('sistema_origen', $request->sistema_origen);
}
return response()->json($query->orderBy('prioridad', 'desc')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'sistema_origen' => 'required|string|max:50',
'cuenta_padre_codigo' => 'nullable|string|max:20',
'rango_inicio' => 'nullable|string|max:20',
'rango_fin' => 'nullable|string|max:20',
'patron_regex' => 'nullable|string|max:255',
'reporte_contable_id' => 'required|exists:reportes_contables,id',
'categoria_contable_id' => 'required|exists:categorias_contables,id',
'prioridad' => 'integer',
'activo' => 'boolean',
]);
$regla = ReglaMapeo::create($validated);
return response()->json($regla->load(['reporteContable', 'categoriaContable']), 201);
}
public function show(ReglaMapeo $reglaMapeo): JsonResponse
{
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
}
public function update(Request $request, ReglaMapeo $reglaMapeo): JsonResponse
{
$validated = $request->validate([
'sistema_origen' => 'string|max:50',
'cuenta_padre_codigo' => 'nullable|string|max:20',
'rango_inicio' => 'nullable|string|max:20',
'rango_fin' => 'nullable|string|max:20',
'patron_regex' => 'nullable|string|max:255',
'reporte_contable_id' => 'exists:reportes_contables,id',
'categoria_contable_id' => 'exists:categorias_contables,id',
'prioridad' => 'integer',
'activo' => 'boolean',
]);
$reglaMapeo->update($validated);
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
}
public function destroy(ReglaMapeo $reglaMapeo): JsonResponse
{
$reglaMapeo->delete();
return response()->json(['message' => 'Regla de mapeo eliminada']);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Umbral;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UmbralController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Umbral::with('giro');
if ($request->has('giro_id')) {
$query->where('giro_id', $request->giro_id);
}
return response()->json($query->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'metrica' => 'required|string|max:100',
'muy_positivo' => 'nullable|numeric',
'positivo' => 'nullable|numeric',
'neutral' => 'nullable|numeric',
'negativo' => 'nullable|numeric',
'muy_negativo' => 'nullable|numeric',
'giro_id' => 'nullable|exists:giros,id',
]);
$umbral = Umbral::create($validated);
return response()->json($umbral->load('giro'), 201);
}
public function show(Umbral $umbral): JsonResponse
{
return response()->json($umbral->load('giro'));
}
public function update(Request $request, Umbral $umbral): JsonResponse
{
$validated = $request->validate([
'metrica' => 'string|max:100',
'muy_positivo' => 'nullable|numeric',
'positivo' => 'nullable|numeric',
'neutral' => 'nullable|numeric',
'negativo' => 'nullable|numeric',
'muy_negativo' => 'nullable|numeric',
'giro_id' => 'nullable|exists:giros,id',
]);
$umbral->update($validated);
return response()->json($umbral->load('giro'));
}
public function destroy(Umbral $umbral): JsonResponse
{
$umbral->delete();
return response()->json(['message' => 'Umbral eliminado']);
}
public function porMetrica(string $metrica, ?int $giroId = null): JsonResponse
{
// Buscar primero umbral específico del giro, luego el genérico
$umbral = Umbral::where('metrica', $metrica)
->where('giro_id', $giroId)
->first();
if (!$umbral) {
$umbral = Umbral::where('metrica', $metrica)
->whereNull('giro_id')
->first();
}
if (!$umbral) {
return response()->json(['message' => 'Umbral no encontrado'], 404);
}
return response()->json($umbral);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class UsuarioController extends Controller
{
public function index(): JsonResponse
{
$usuarios = User::with('cliente')->get();
return response()->json($usuarios);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'nombre' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|in:admin,analista,cliente,empleado',
'cliente_id' => 'nullable|exists:clientes,id',
]);
$usuario = User::create([
'nombre' => $validated['nombre'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
'cliente_id' => $validated['cliente_id'] ?? null,
]);
return response()->json($usuario->load('cliente'), 201);
}
public function show(User $usuario): JsonResponse
{
return response()->json($usuario->load('cliente'));
}
public function update(Request $request, User $usuario): JsonResponse
{
$validated = $request->validate([
'nombre' => 'string|max:255',
'email' => 'email|unique:users,email,' . $usuario->id,
'password' => 'nullable|string|min:8',
'role' => 'in:admin,analista,cliente,empleado',
'cliente_id' => 'nullable|exists:clientes,id',
]);
if (isset($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
unset($validated['password']);
}
$usuario->update($validated);
return response()->json($usuario->load('cliente'));
}
public function destroy(User $usuario): JsonResponse
{
$usuario->delete();
return response()->json(['message' => 'Usuario eliminado']);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Las credenciales proporcionadas son incorrectas.'],
]);
}
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => $user->load('cliente'),
'token' => $token,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Sesión cerrada exitosamente']);
}
public function user(Request $request): JsonResponse
{
return response()->json($request->user()->load('cliente'));
}
public function register(Request $request): JsonResponse
{
// Solo admin puede registrar usuarios
if (!$request->user()->isAdmin()) {
return response()->json(['message' => 'No autorizado'], 403);
}
$validated = $request->validate([
'nombre' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|in:admin,analista,cliente,empleado',
'cliente_id' => 'nullable|exists:clientes,id',
]);
$user = User::create([
'nombre' => $validated['nombre'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
'cliente_id' => $validated['cliente_id'] ?? null,
]);
return response()->json($user, 201);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers;
use App\Models\Balanza;
use App\Models\Cliente;
use App\Services\Parsers\DetectorFormato;
use App\Services\ClasificadorCuentas;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BalanzaController extends Controller
{
public function __construct(
private DetectorFormato $detector,
private ClasificadorCuentas $clasificador,
) {}
public function index(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$balanzas = $cliente->balanzas()
->orderByDesc('periodo_fin')
->get();
return response()->json($balanzas);
}
public function store(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$request->validate([
'archivo' => 'required|file|mimes:pdf,xlsx,xls,csv|max:10240',
'periodo_inicio' => 'required|date',
'periodo_fin' => 'required|date|after_or_equal:periodo_inicio',
]);
$file = $request->file('archivo');
$path = $file->store('balanzas/' . $cliente->id, 'local');
$balanza = Balanza::create([
'cliente_id' => $cliente->id,
'periodo_inicio' => $request->periodo_inicio,
'periodo_fin' => $request->periodo_fin,
'archivo_original' => $path,
'sistema_origen' => 'pendiente',
'status' => 'pendiente',
]);
// Procesar archivo en background o inmediatamente
try {
$this->procesarBalanza($balanza, $path);
} catch (\Exception $e) {
$balanza->update([
'status' => 'error',
'error_mensaje' => $e->getMessage(),
]);
}
return response()->json($balanza, 201);
}
public function show(Request $request, Balanza $balanza): JsonResponse
{
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
return response()->json($balanza->load(['cuentas', 'cliente']));
}
public function cuentas(Request $request, Balanza $balanza): JsonResponse
{
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$cuentas = $balanza->cuentas()
->with(['categoriaContable', 'reporteContable', 'cuentaPadre'])
->orderBy('codigo')
->get();
return response()->json($cuentas);
}
public function updateExclusiones(Request $request, Balanza $balanza): JsonResponse
{
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$request->validate([
'exclusiones' => 'required|array',
'exclusiones.*' => 'integer|exists:cuentas,id',
]);
// Marcar todas como incluidas primero
$balanza->cuentas()->update(['excluida' => false]);
// Marcar las seleccionadas como excluidas
$balanza->cuentas()
->whereIn('id', $request->exclusiones)
->update(['excluida' => true]);
// Recalcular saldos de cuentas padre
$cuentasPadre = $balanza->cuentasPadre()->get();
foreach ($cuentasPadre as $cuentaPadre) {
$cuentaPadre->recalcularSaldoDesdeHijos();
}
return response()->json(['message' => 'Exclusiones actualizadas']);
}
private function procesarBalanza(Balanza $balanza, string $path): void
{
$balanza->update(['status' => 'procesando']);
$fullPath = Storage::disk('local')->path($path);
// Detectar sistema origen
$resultado = $this->detector->detectar($fullPath);
$balanza->update(['sistema_origen' => $resultado['sistema']]);
// Parsear y guardar cuentas
$cuentas = $resultado['parser']->parsear($fullPath);
foreach ($cuentas as $cuentaData) {
$balanza->cuentas()->create($cuentaData);
}
// Clasificar cuentas
$this->clasificador->clasificar($balanza);
$balanza->update(['status' => 'completado']);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cliente;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ClienteController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
if ($user->isAdmin() || $user->isAnalista()) {
$clientes = Cliente::with('giro')->get();
} else {
$clientes = Cliente::where('id', $user->cliente_id)->with('giro')->get();
}
return response()->json($clientes);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'nombre_empresa' => 'required|string|max:255',
'giro_id' => 'required|exists:giros,id',
'moneda' => 'string|max:3',
'logo' => 'nullable|image|max:2048',
]);
$cliente = new Cliente([
'nombre_empresa' => $validated['nombre_empresa'],
'giro_id' => $validated['giro_id'],
'moneda' => $validated['moneda'] ?? 'MXN',
]);
if ($request->hasFile('logo')) {
$path = $request->file('logo')->store('logos', 'public');
$cliente->logo = $path;
}
$cliente->save();
return response()->json($cliente->load('giro'), 201);
}
public function show(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
return response()->json($cliente->load(['giro', 'balanzas', 'reportes']));
}
public function update(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$validated = $request->validate([
'nombre_empresa' => 'string|max:255',
'giro_id' => 'exists:giros,id',
'moneda' => 'string|max:3',
'logo' => 'nullable|image|max:2048',
'configuracion' => 'nullable|array',
]);
if ($request->hasFile('logo')) {
if ($cliente->logo) {
Storage::disk('public')->delete($cliente->logo);
}
$validated['logo'] = $request->file('logo')->store('logos', 'public');
}
$cliente->update($validated);
return response()->json($cliente->load('giro'));
}
public function destroy(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->isAdmin()) {
return response()->json(['message' => 'No autorizado'], 403);
}
if ($cliente->logo) {
Storage::disk('public')->delete($cliente->logo);
}
$cliente->delete();
return response()->json(['message' => 'Cliente eliminado']);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cuenta;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CuentaController extends Controller
{
public function updateClasificacion(Request $request, Cuenta $cuenta): JsonResponse
{
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$validated = $request->validate([
'reporte_contable_id' => 'required|exists:reportes_contables,id',
'categoria_contable_id' => 'required|exists:categorias_contables,id',
'requiere_revision' => 'boolean',
'nota_revision' => 'nullable|string',
]);
$cuenta->update([
'reporte_contable_id' => $validated['reporte_contable_id'],
'categoria_contable_id' => $validated['categoria_contable_id'],
'requiere_revision' => $validated['requiere_revision'] ?? false,
'nota_revision' => $validated['nota_revision'] ?? null,
]);
return response()->json($cuenta->load(['categoriaContable', 'reporteContable']));
}
public function toggleExclusion(Request $request, Cuenta $cuenta): JsonResponse
{
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$cuenta->update(['excluida' => !$cuenta->excluida]);
// Recalcular cuenta padre si existe
if ($cuenta->cuentaPadre) {
$cuenta->cuentaPadre->recalcularSaldoDesdeHijos();
}
return response()->json($cuenta);
}
public function anomalias(Request $request): JsonResponse
{
$user = $request->user();
$query = Cuenta::where('requiere_revision', true)
->with(['balanza.cliente', 'categoriaContable', 'reporteContable']);
if (!$user->isAdmin() && !$user->isAnalista()) {
$query->whereHas('balanza', function ($q) use ($user) {
$q->where('cliente_id', $user->cliente_id);
});
}
return response()->json($query->get());
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cliente;
use App\Models\Reporte;
use App\Services\CalculadorMetricas;
use App\Services\GeneradorPdf;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ReporteController extends Controller
{
public function __construct(
private CalculadorMetricas $calculador,
private GeneradorPdf $generadorPdf,
) {}
public function index(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$reportes = $cliente->reportes()
->orderByDesc('periodo_fin')
->get();
return response()->json($reportes);
}
public function store(Request $request, Cliente $cliente): JsonResponse
{
if (!$request->user()->canAccessCliente($cliente->id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
$validated = $request->validate([
'nombre' => 'required|string|max:255',
'balanza_ids' => 'required|array|min:1',
'balanza_ids.*' => 'exists:balanzas,id',
]);
// Verificar que las balanzas pertenecen al cliente
$balanzasValidas = $cliente->balanzas()
->whereIn('id', $validated['balanza_ids'])
->where('status', 'completado')
->get();
if ($balanzasValidas->count() !== count($validated['balanza_ids'])) {
return response()->json([
'message' => 'Algunas balanzas no son válidas o no están procesadas'
], 422);
}
// Determinar tipo de periodo
$periodoTipo = $this->determinarTipoPeriodo($balanzasValidas);
$reporte = Reporte::create([
'cliente_id' => $cliente->id,
'nombre' => $validated['nombre'],
'periodo_tipo' => $periodoTipo,
'periodo_inicio' => $balanzasValidas->min('periodo_inicio'),
'periodo_fin' => $balanzasValidas->max('periodo_fin'),
'status' => 'procesando',
]);
$reporte->balanzas()->attach($validated['balanza_ids']);
// Calcular métricas
try {
$dataCalculada = $this->calculador->calcular($reporte);
$reporte->update([
'data_calculada' => $dataCalculada,
'fecha_generacion' => now(),
'status' => 'completado',
]);
} catch (\Exception $e) {
$reporte->update(['status' => 'error']);
return response()->json(['message' => $e->getMessage()], 500);
}
return response()->json($reporte, 201);
}
public function show(Request $request, Reporte $reporte): JsonResponse
{
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
return response()->json($reporte->load(['cliente', 'balanzas']));
}
public function pdf(Request $request, Reporte $reporte): BinaryFileResponse|JsonResponse
{
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
return response()->json(['message' => 'No autorizado'], 403);
}
// Verificar permisos de empleado si aplica
$user = $request->user();
if ($user->isEmpleado()) {
$permiso = $user->permisosEmpleado()
->where('cliente_id', $reporte->cliente_id)
->first();
if (!$permiso || !$permiso->tienePermiso('exportar_pdf')) {
return response()->json(['message' => 'No tiene permiso para exportar PDF'], 403);
}
}
if (!$reporte->pdf_path || !file_exists(storage_path('app/' . $reporte->pdf_path))) {
// Generar PDF
$pdfPath = $this->generadorPdf->generar($reporte);
$reporte->update(['pdf_path' => $pdfPath]);
}
return response()->download(
storage_path('app/' . $reporte->pdf_path),
$reporte->nombre . '.pdf'
);
}
public function destroy(Request $request, Reporte $reporte): JsonResponse
{
if (!$request->user()->isAdmin() && !$request->user()->isAnalista()) {
return response()->json(['message' => 'No autorizado'], 403);
}
$reporte->delete();
return response()->json(['message' => 'Reporte eliminado']);
}
private function determinarTipoPeriodo($balanzas): string
{
if ($balanzas->count() < 2) {
$balanza = $balanzas->first();
$dias = $balanza->periodo_inicio->diffInDays($balanza->periodo_fin);
if ($dias <= 35) return 'mensual';
if ($dias <= 100) return 'trimestral';
return 'anual';
}
// Calcular diferencia promedio entre periodos
$diferencias = [];
$sorted = $balanzas->sortBy('periodo_inicio')->values();
for ($i = 1; $i < $sorted->count(); $i++) {
$diferencias[] = $sorted[$i - 1]->periodo_fin->diffInDays($sorted[$i]->periodo_inicio);
}
$promedio = array_sum($diferencias) / count($diferencias);
if ($promedio <= 35) return 'mensual';
if ($promedio <= 100) return 'trimestral';
return 'anual';
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param string ...$roles
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'No autenticado'], 401);
}
if (!in_array($user->role, $roles)) {
return response()->json(['message' => 'No autorizado'], 403);
}
return $next($request);
}
}