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

31
backend/.env.example Normal file
View File

@@ -0,0 +1,31 @@
APP_NAME="Horux Strategy"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:5173
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=horux_strategy
DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost:3000,127.0.0.1:5173
# Browsershot (para generación de PDF)
# Instalar Chromium: npx puppeteer browsers install chrome
BROWSERSHOT_NODE_PATH=/usr/bin/node
BROWSERSHOT_NPM_PATH=/usr/bin/npm

107
backend/README.md Normal file
View File

@@ -0,0 +1,107 @@
# Horux Strategy Platform - Backend
API REST con Laravel 11 para la plataforma de reportes financieros.
## Requisitos
- PHP 8.2+
- Composer
- MySQL 8.0+ o PostgreSQL 14+
- Node.js 18+ (para Browsershot/PDF)
## Instalación
```bash
# Instalar dependencias
composer install
# Copiar configuración
cp .env.example .env
# Generar key
php artisan key:generate
# Configurar base de datos en .env
# DB_DATABASE=horux_strategy
# DB_USERNAME=root
# DB_PASSWORD=
# Ejecutar migraciones y seeders
php artisan migrate --seed
# Crear link storage
php artisan storage:link
# Iniciar servidor
php artisan serve
```
## Credenciales por defecto
- **Admin**: admin@horux360.com / password
## Endpoints principales
### Autenticación
- `POST /api/login` - Iniciar sesión
- `POST /api/logout` - Cerrar sesión
- `GET /api/user` - Usuario actual
### Clientes
- `GET /api/clientes` - Listar clientes
- `POST /api/clientes` - Crear cliente
- `GET /api/clientes/{id}` - Ver cliente
- `PUT /api/clientes/{id}` - Actualizar cliente
- `DELETE /api/clientes/{id}` - Eliminar cliente
### Balanzas
- `GET /api/clientes/{id}/balanzas` - Listar balanzas
- `POST /api/clientes/{id}/balanzas` - Subir balanza
- `GET /api/balanzas/{id}/cuentas` - Ver cuentas
- `PUT /api/balanzas/{id}/exclusiones` - Actualizar exclusiones
### Reportes
- `GET /api/clientes/{id}/reportes` - Listar reportes
- `POST /api/clientes/{id}/reportes` - Generar reporte
- `GET /api/reportes/{id}` - Ver reporte
- `GET /api/reportes/{id}/pdf` - Descargar PDF
### Administración (solo admin)
- `GET/POST/PUT/DELETE /api/admin/usuarios`
- `GET/POST/PUT/DELETE /api/admin/giros`
- `GET/POST/PUT/DELETE /api/admin/umbrales`
- `GET/POST/PUT/DELETE /api/admin/reglas-mapeo`
## Estructura de carpetas
```
app/
├── Http/Controllers/
│ ├── AuthController.php
│ ├── ClienteController.php
│ ├── BalanzaController.php
│ ├── CuentaController.php
│ ├── ReporteController.php
│ └── Admin/
├── Models/
├── Services/
│ ├── Parsers/
│ │ ├── ParserInterface.php
│ │ ├── ContpaqiParser.php
│ │ ├── GenericoParser.php
│ │ └── DetectorFormato.php
│ ├── ClasificadorCuentas.php
│ ├── CalculadorMetricas.php
│ └── GeneradorPdf.php
└── ...
```
## Agregar nuevo parser
1. Crear clase en `app/Services/Parsers/` implementando `ParserInterface`
2. Registrar en `DetectorFormato::__construct()`
3. Agregar reglas de mapeo en seeder
## Licencia
Propietario - Horux 360

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);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Balanza extends Model
{
use HasFactory;
protected $fillable = [
'cliente_id',
'periodo_inicio',
'periodo_fin',
'sistema_origen',
'archivo_original',
'status',
'error_mensaje',
];
protected function casts(): array
{
return [
'periodo_inicio' => 'date',
'periodo_fin' => 'date',
];
}
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
public function cuentas(): HasMany
{
return $this->hasMany(Cuenta::class);
}
public function reportes(): BelongsToMany
{
return $this->belongsToMany(Reporte::class, 'reporte_balanza');
}
public function cuentasActivas(): HasMany
{
return $this->hasMany(Cuenta::class)->where('excluida', false);
}
public function cuentasPadre(): HasMany
{
return $this->hasMany(Cuenta::class)->where('es_cuenta_padre', true);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class CategoriaContable extends Model
{
use HasFactory;
protected $table = 'categorias_contables';
protected $fillable = [
'reporte_contable_id',
'nombre',
'orden',
];
public function reporteContable(): BelongsTo
{
return $this->belongsTo(ReporteContable::class);
}
public function cuentas(): HasMany
{
return $this->hasMany(Cuenta::class);
}
public function reglasMapeoo(): HasMany
{
return $this->hasMany(ReglaMapeoo::class);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Cliente extends Model
{
use HasFactory;
protected $fillable = [
'nombre_empresa',
'logo',
'giro_id',
'moneda',
'configuracion',
];
protected function casts(): array
{
return [
'configuracion' => 'array',
];
}
public function giro(): BelongsTo
{
return $this->belongsTo(Giro::class);
}
public function usuarios(): HasMany
{
return $this->hasMany(User::class);
}
public function balanzas(): HasMany
{
return $this->hasMany(Balanza::class);
}
public function reportes(): HasMany
{
return $this->hasMany(Reporte::class);
}
public function mapeoCuentas(): HasMany
{
return $this->hasMany(MapeoCuenta::class);
}
public function permisosEmpleado(): HasMany
{
return $this->hasMany(PermisoEmpleado::class);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Cuenta extends Model
{
use HasFactory;
protected $fillable = [
'balanza_id',
'codigo',
'nombre',
'nivel',
'reporte_contable_id',
'categoria_contable_id',
'cuenta_padre_id',
'saldo_inicial_deudor',
'saldo_inicial_acreedor',
'cargos',
'abonos',
'saldo_final_deudor',
'saldo_final_acreedor',
'excluida',
'es_cuenta_padre',
'requiere_revision',
'nota_revision',
];
protected function casts(): array
{
return [
'saldo_inicial_deudor' => 'decimal:2',
'saldo_inicial_acreedor' => 'decimal:2',
'cargos' => 'decimal:2',
'abonos' => 'decimal:2',
'saldo_final_deudor' => 'decimal:2',
'saldo_final_acreedor' => 'decimal:2',
'excluida' => 'boolean',
'es_cuenta_padre' => 'boolean',
'requiere_revision' => 'boolean',
];
}
public function balanza(): BelongsTo
{
return $this->belongsTo(Balanza::class);
}
public function reporteContable(): BelongsTo
{
return $this->belongsTo(ReporteContable::class);
}
public function categoriaContable(): BelongsTo
{
return $this->belongsTo(CategoriaContable::class);
}
public function cuentaPadre(): BelongsTo
{
return $this->belongsTo(Cuenta::class, 'cuenta_padre_id');
}
public function cuentasHijo(): HasMany
{
return $this->hasMany(Cuenta::class, 'cuenta_padre_id');
}
public function getSaldoInicialNetoAttribute(): float
{
return $this->saldo_inicial_deudor - $this->saldo_inicial_acreedor;
}
public function getSaldoFinalNetoAttribute(): float
{
return $this->saldo_final_deudor - $this->saldo_final_acreedor;
}
public function getMovimientoNetoAttribute(): float
{
return $this->cargos - $this->abonos;
}
/**
* Recalcula el saldo considerando solo cuentas hijo activas
*/
public function recalcularSaldoDesdeHijos(): void
{
if (!$this->es_cuenta_padre) {
return;
}
$hijosActivos = $this->cuentasHijo()->where('excluida', false)->get();
$this->saldo_inicial_deudor = $hijosActivos->sum('saldo_inicial_deudor');
$this->saldo_inicial_acreedor = $hijosActivos->sum('saldo_inicial_acreedor');
$this->cargos = $hijosActivos->sum('cargos');
$this->abonos = $hijosActivos->sum('abonos');
$this->saldo_final_deudor = $hijosActivos->sum('saldo_final_deudor');
$this->saldo_final_acreedor = $hijosActivos->sum('saldo_final_acreedor');
$this->save();
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Giro extends Model
{
use HasFactory;
protected $fillable = [
'nombre',
'activo',
];
protected function casts(): array
{
return [
'activo' => 'boolean',
];
}
public function clientes(): HasMany
{
return $this->hasMany(Cliente::class);
}
public function umbrales(): HasMany
{
return $this->hasMany(Umbral::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MapeoCuenta extends Model
{
use HasFactory;
protected $table = 'mapeo_cuentas';
protected $fillable = [
'cliente_id',
'codigo_patron',
'categoria_contable_id',
'notas',
];
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
public function categoriaContable(): BelongsTo
{
return $this->belongsTo(CategoriaContable::class);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PermisoEmpleado extends Model
{
use HasFactory;
protected $table = 'permisos_empleado';
protected $fillable = [
'user_id',
'cliente_id',
'permisos',
];
protected function casts(): array
{
return [
'permisos' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
public function tienePermiso(string $permiso): bool
{
return in_array($permiso, $this->permisos ?? []);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReglaMapeo extends Model
{
use HasFactory;
protected $table = 'reglas_mapeo';
protected $fillable = [
'sistema_origen',
'cuenta_padre_codigo',
'rango_inicio',
'rango_fin',
'patron_regex',
'reporte_contable_id',
'categoria_contable_id',
'prioridad',
'activo',
];
protected function casts(): array
{
return [
'activo' => 'boolean',
];
}
public function reporteContable(): BelongsTo
{
return $this->belongsTo(ReporteContable::class);
}
public function categoriaContable(): BelongsTo
{
return $this->belongsTo(CategoriaContable::class);
}
/**
* Verifica si un código de cuenta coincide con esta regla
*/
public function coincideCon(string $codigo): bool
{
// Si tiene patrón regex, usarlo
if ($this->patron_regex) {
return (bool) preg_match($this->patron_regex, $codigo);
}
// Si tiene rango, verificar
if ($this->rango_inicio && $this->rango_fin) {
$codigoNumerico = $this->codigoANumero($codigo);
$inicioNumerico = $this->codigoANumero($this->rango_inicio);
$finNumerico = $this->codigoANumero($this->rango_fin);
return $codigoNumerico >= $inicioNumerico && $codigoNumerico <= $finNumerico;
}
return false;
}
/**
* Convierte código tipo "001-100-000" a número para comparación
*/
private function codigoANumero(string $codigo): int
{
return (int) str_replace(['-', '.', ' '], '', $codigo);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Reporte extends Model
{
use HasFactory;
protected $fillable = [
'cliente_id',
'nombre',
'periodo_tipo',
'periodo_inicio',
'periodo_fin',
'fecha_generacion',
'data_calculada',
'pdf_path',
'status',
];
protected function casts(): array
{
return [
'periodo_inicio' => 'date',
'periodo_fin' => 'date',
'fecha_generacion' => 'datetime',
'data_calculada' => 'array',
];
}
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
public function balanzas(): BelongsToMany
{
return $this->belongsToMany(Balanza::class, 'reporte_balanza');
}
public function getMetrica(string $nombre): ?array
{
return $this->data_calculada['metricas'][$nombre] ?? null;
}
public function getEstadoFinanciero(string $tipo): ?array
{
return $this->data_calculada['estados_financieros'][$tipo] ?? null;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ReporteContable extends Model
{
use HasFactory;
protected $table = 'reportes_contables';
protected $fillable = [
'nombre',
];
public function categorias(): HasMany
{
return $this->hasMany(CategoriaContable::class)->orderBy('orden');
}
public function cuentas(): HasMany
{
return $this->hasMany(Cuenta::class);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Umbral extends Model
{
use HasFactory;
protected $table = 'umbrales';
protected $fillable = [
'metrica',
'muy_positivo',
'positivo',
'neutral',
'negativo',
'muy_negativo',
'giro_id',
];
protected function casts(): array
{
return [
'muy_positivo' => 'decimal:4',
'positivo' => 'decimal:4',
'neutral' => 'decimal:4',
'negativo' => 'decimal:4',
'muy_negativo' => 'decimal:4',
];
}
public function giro(): BelongsTo
{
return $this->belongsTo(Giro::class);
}
public function evaluarValor(float $valor): string
{
if ($this->muy_positivo !== null && $valor >= $this->muy_positivo) {
return 'muy_positivo';
}
if ($this->positivo !== null && $valor >= $this->positivo) {
return 'positivo';
}
if ($this->neutral !== null && $valor >= $this->neutral) {
return 'neutral';
}
if ($this->negativo !== null && $valor >= $this->negativo) {
return 'negativo';
}
return 'muy_negativo';
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'nombre',
'email',
'password',
'role',
'cliente_id',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function cliente(): BelongsTo
{
return $this->belongsTo(Cliente::class);
}
public function permisosEmpleado(): HasMany
{
return $this->hasMany(PermisoEmpleado::class);
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isAnalista(): bool
{
return $this->role === 'analista';
}
public function isCliente(): bool
{
return $this->role === 'cliente';
}
public function isEmpleado(): bool
{
return $this->role === 'empleado';
}
public function canAccessCliente(int $clienteId): bool
{
if ($this->isAdmin() || $this->isAnalista()) {
return true;
}
return $this->cliente_id === $clienteId;
}
}

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;
}

26
backend/bootstrap/app.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
$middleware->alias([
'role' => \App\Http\Middleware\RoleMiddleware::class,
]);
$middleware->statefulApi();
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

70
backend/composer.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "horux360/strategy-platform",
"type": "project",
"description": "Plataforma de Reportes Financieros Horux Strategy",
"keywords": ["laravel", "financial", "reports", "horux"],
"license": "proprietary",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9",
"spatie/pdf-to-text": "^1.52",
"phpoffice/phpspreadsheet": "^2.0",
"spatie/browsershot": "^4.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0",
"spatie/laravel-ignition": "^2.4"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

26
backend/config/app.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
return [
'name' => env('APP_NAME', 'Horux Strategy'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
'timezone' => 'America/Mexico_City',
'locale' => 'es',
'fallback_locale' => 'en',
'faker_locale' => 'es_MX',
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

32
backend/config/cors.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => [
'http://localhost:5173',
'http://localhost:3000',
'http://127.0.0.1:5173',
'http://127.0.0.1:3000',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];

View File

@@ -0,0 +1,59 @@
<?php
return [
'default' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'monthly_platform'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'monthly_platform'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
];

View File

@@ -0,0 +1,53 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,localhost:5173,127.0.0.1,127.0.0.1:8000,127.0.0.1:5173,::1',
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('giros', function (Blueprint $table) {
$table->id();
$table->string('nombre');
$table->boolean('activo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('giros');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('clientes', function (Blueprint $table) {
$table->id();
$table->string('nombre_empresa');
$table->string('logo')->nullable();
$table->foreignId('giro_id')->constrained('giros');
$table->string('moneda')->default('MXN');
$table->json('configuracion')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('clientes');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('nombre');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->enum('role', ['admin', 'analista', 'cliente', 'empleado']);
$table->foreignId('cliente_id')->nullable()->constrained('clientes')->nullOnDelete();
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('permisos_empleado', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
$table->json('permisos'); // {ver_dashboard, ver_estados, exportar_pdf, etc.}
$table->timestamps();
$table->unique(['user_id', 'cliente_id']);
});
}
public function down(): void
{
Schema::dropIfExists('permisos_empleado');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('reportes_contables', function (Blueprint $table) {
$table->id();
$table->string('nombre'); // "Balance General", "Estado de Resultados"
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('reportes_contables');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('categorias_contables', function (Blueprint $table) {
$table->id();
$table->foreignId('reporte_contable_id')->constrained('reportes_contables')->cascadeOnDelete();
$table->string('nombre');
$table->integer('orden');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('categorias_contables');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('umbrales', function (Blueprint $table) {
$table->id();
$table->string('metrica'); // nombre de la métrica
$table->decimal('muy_positivo', 10, 4)->nullable();
$table->decimal('positivo', 10, 4)->nullable();
$table->decimal('neutral', 10, 4)->nullable();
$table->decimal('negativo', 10, 4)->nullable();
$table->decimal('muy_negativo', 10, 4)->nullable();
$table->foreignId('giro_id')->nullable()->constrained('giros')->nullOnDelete();
$table->timestamps();
$table->unique(['metrica', 'giro_id']);
});
}
public function down(): void
{
Schema::dropIfExists('umbrales');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('balanzas', function (Blueprint $table) {
$table->id();
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
$table->date('periodo_inicio');
$table->date('periodo_fin');
$table->string('sistema_origen'); // contpaqi, aspel, sap, etc.
$table->string('archivo_original');
$table->enum('status', ['pendiente', 'procesando', 'completado', 'error'])->default('pendiente');
$table->text('error_mensaje')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('balanzas');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cuentas', function (Blueprint $table) {
$table->id();
$table->foreignId('balanza_id')->constrained('balanzas')->cascadeOnDelete();
$table->string('codigo'); // ej: 001-100-000
$table->string('nombre');
$table->integer('nivel'); // nivel jerárquico
$table->foreignId('reporte_contable_id')->nullable()->constrained('reportes_contables');
$table->foreignId('categoria_contable_id')->nullable()->constrained('categorias_contables');
$table->foreignId('cuenta_padre_id')->nullable()->constrained('cuentas')->nullOnDelete();
$table->decimal('saldo_inicial_deudor', 18, 2)->default(0);
$table->decimal('saldo_inicial_acreedor', 18, 2)->default(0);
$table->decimal('cargos', 18, 2)->default(0);
$table->decimal('abonos', 18, 2)->default(0);
$table->decimal('saldo_final_deudor', 18, 2)->default(0);
$table->decimal('saldo_final_acreedor', 18, 2)->default(0);
$table->boolean('excluida')->default(false);
$table->boolean('es_cuenta_padre')->default(false);
$table->boolean('requiere_revision')->default(false);
$table->text('nota_revision')->nullable();
$table->timestamps();
$table->index(['balanza_id', 'codigo']);
});
}
public function down(): void
{
Schema::dropIfExists('cuentas');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('reglas_mapeo', function (Blueprint $table) {
$table->id();
$table->string('sistema_origen'); // contpaqi, aspel, etc.
$table->string('cuenta_padre_codigo')->nullable(); // ej: 001-100-000
$table->string('rango_inicio')->nullable(); // ej: 101-000-000
$table->string('rango_fin')->nullable(); // ej: 154-999-999
$table->string('patron_regex')->nullable(); // para casos especiales
$table->foreignId('reporte_contable_id')->constrained('reportes_contables');
$table->foreignId('categoria_contable_id')->constrained('categorias_contables');
$table->integer('prioridad')->default(0);
$table->boolean('activo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('reglas_mapeo');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mapeo_cuentas', function (Blueprint $table) {
$table->id();
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
$table->string('codigo_patron'); // patrón de código para matching
$table->foreignId('categoria_contable_id')->constrained('categorias_contables');
$table->text('notas')->nullable();
$table->timestamps();
$table->unique(['cliente_id', 'codigo_patron']);
});
}
public function down(): void
{
Schema::dropIfExists('mapeo_cuentas');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('reportes', function (Blueprint $table) {
$table->id();
$table->foreignId('cliente_id')->constrained('clientes')->cascadeOnDelete();
$table->string('nombre');
$table->enum('periodo_tipo', ['mensual', 'trimestral', 'anual']);
$table->date('periodo_inicio');
$table->date('periodo_fin');
$table->timestamp('fecha_generacion')->nullable();
$table->json('data_calculada')->nullable(); // métricas, estados financieros, etc.
$table->string('pdf_path')->nullable();
$table->enum('status', ['pendiente', 'procesando', 'completado', 'error'])->default('pendiente');
$table->timestamps();
});
// Tabla pivote para relacionar reportes con balanzas
Schema::create('reporte_balanza', function (Blueprint $table) {
$table->foreignId('reporte_id')->constrained('reportes')->cascadeOnDelete();
$table->foreignId('balanza_id')->constrained('balanzas')->cascadeOnDelete();
$table->primary(['reporte_id', 'balanza_id']);
});
}
public function down(): void
{
Schema::dropIfExists('reporte_balanza');
Schema::dropIfExists('reportes');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
User::create([
'nombre' => 'Administrador',
'email' => 'admin@horux360.com',
'password' => Hash::make('password'),
'role' => 'admin',
'cliente_id' => null,
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
GirosSeeder::class,
ReportesContablesSeeder::class,
ReglasMapeeoContpaqiSeeder::class,
UmbralesSeeder::class,
AdminUserSeeder::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use App\Models\Giro;
use Illuminate\Database\Seeder;
class GirosSeeder extends Seeder
{
public function run(): void
{
$giros = [
'Agricultura y Ganadería',
'Alimentos y Bebidas',
'Automotriz',
'Comercio al por Mayor',
'Comercio al por Menor',
'Construcción',
'Educación',
'Energía',
'Farmacéutica',
'Financiero',
'Hotelería',
'Inmobiliario',
'Logística y Transporte',
'Manufactura',
'Medios y Entretenimiento',
'Minería',
'Restaurantes',
'Salud',
'Servicios Profesionales',
'Tecnología',
'Telecomunicaciones',
'Textil y Moda',
'Turismo',
'Otro',
];
foreach ($giros as $nombre) {
Giro::create(['nombre' => $nombre, 'activo' => true]);
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Database\Seeders;
use App\Models\ReglaMapeo;
use App\Models\ReporteContable;
use App\Models\CategoriaContable;
use Illuminate\Database\Seeder;
class ReglasMapeeoContpaqiSeeder extends Seeder
{
public function run(): void
{
$balanceGeneral = ReporteContable::where('nombre', 'Balance General')->first();
$estadoResultados = ReporteContable::where('nombre', 'Estado de Resultados')->first();
// Obtener categorías
$activosCirculantes = CategoriaContable::where('nombre', 'Activos Circulantes')->first();
$activosNoCirculantes = CategoriaContable::where('nombre', 'Activos No Circulantes')->first();
$pasivoCirculante = CategoriaContable::where('nombre', 'Pasivo Circulante')->first();
$pasivoNoCirculante = CategoriaContable::where('nombre', 'Pasivo No Circulante')->first();
$capitalSocial = CategoriaContable::where('nombre', 'Capital Social')->first();
$perdidasAnteriores = CategoriaContable::where('nombre', 'Pérdidas Ejercicios Anteriores')->first();
$utilidadesAnteriores = CategoriaContable::where('nombre', 'Utilidades Ejercicios Anteriores')->first();
$ingresos = CategoriaContable::where('nombre', 'Ingresos')->first();
$costoVenta = CategoriaContable::where('nombre', 'Costo de Venta')->first();
$gastosOperativos = CategoriaContable::where('nombre', 'Gastos Operativos')->first();
$otrosGastos = CategoriaContable::where('nombre', 'Otros Gastos')->first();
$gastosFinancieros = CategoriaContable::where('nombre', 'Gastos Financieros')->first();
$reglas = [
// Activos Circulantes: 001-100-000, hijos 101-000-000 a 154-999-999
[
'sistema_origen' => 'contpaqi',
'cuenta_padre_codigo' => '001-100-000',
'rango_inicio' => '101-000-000',
'rango_fin' => '154-999-999',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $activosCirculantes->id,
'prioridad' => 10,
],
// Activos No Circulantes: 001-200-000, hijos 155-000-000 a 199-999-999
[
'sistema_origen' => 'contpaqi',
'cuenta_padre_codigo' => '001-200-000',
'rango_inicio' => '155-000-000',
'rango_fin' => '199-999-999',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $activosNoCirculantes->id,
'prioridad' => 10,
],
// Pasivo Circulante: 002-100-000, hijos 200-000-000 a 209-999-999
[
'sistema_origen' => 'contpaqi',
'cuenta_padre_codigo' => '002-100-000',
'rango_inicio' => '200-000-000',
'rango_fin' => '209-999-999',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $pasivoCirculante->id,
'prioridad' => 10,
],
// Pasivo No Circulante: 002-200-000, hijos 210-000-000 a 220-999-999
[
'sistema_origen' => 'contpaqi',
'cuenta_padre_codigo' => '002-200-000',
'rango_inicio' => '210-000-000',
'rango_fin' => '220-999-999',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $pasivoNoCirculante->id,
'prioridad' => 10,
],
// Capital Social: 300-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^30[0-9]-/',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $capitalSocial->id,
'prioridad' => 10,
],
// Pérdidas Ejercicios Anteriores: 310-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^310-/',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $perdidasAnteriores->id,
'prioridad' => 15,
],
// Utilidades Ejercicios Anteriores: 311-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^311-/',
'reporte_contable_id' => $balanceGeneral->id,
'categoria_contable_id' => $utilidadesAnteriores->id,
'prioridad' => 15,
],
// Ingresos: 40X-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^40[0-9]-/',
'reporte_contable_id' => $estadoResultados->id,
'categoria_contable_id' => $ingresos->id,
'prioridad' => 10,
],
// Costo de Venta: podría ser 410-XXX o 50X en algunos casos
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^41[0-9]-/',
'reporte_contable_id' => $estadoResultados->id,
'categoria_contable_id' => $costoVenta->id,
'prioridad' => 10,
],
// Gastos Operativos: 5XX-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^5[0-9]{2}-/',
'reporte_contable_id' => $estadoResultados->id,
'categoria_contable_id' => $gastosOperativos->id,
'prioridad' => 10,
],
// Otros Gastos: 6XX-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^6[0-9]{2}-/',
'reporte_contable_id' => $estadoResultados->id,
'categoria_contable_id' => $otrosGastos->id,
'prioridad' => 10,
],
// Gastos Financieros: 7XX-XXX-XXX
[
'sistema_origen' => 'contpaqi',
'patron_regex' => '/^7[0-9]{2}-/',
'reporte_contable_id' => $estadoResultados->id,
'categoria_contable_id' => $gastosFinancieros->id,
'prioridad' => 10,
],
];
foreach ($reglas as $regla) {
ReglaMapeo::create(array_merge($regla, ['activo' => true]));
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Database\Seeders;
use App\Models\ReporteContable;
use App\Models\CategoriaContable;
use Illuminate\Database\Seeder;
class ReportesContablesSeeder extends Seeder
{
public function run(): void
{
// Balance General
$balanceGeneral = ReporteContable::create(['nombre' => 'Balance General']);
$categoriasBalance = [
['nombre' => 'Activos Circulantes', 'orden' => 1],
['nombre' => 'Activos No Circulantes', 'orden' => 2],
['nombre' => 'Pasivo Circulante', 'orden' => 3],
['nombre' => 'Pasivo No Circulante', 'orden' => 4],
['nombre' => 'Capital Social', 'orden' => 5],
['nombre' => 'Pérdidas Ejercicios Anteriores', 'orden' => 6],
['nombre' => 'Utilidades Ejercicios Anteriores', 'orden' => 7],
];
foreach ($categoriasBalance as $categoria) {
CategoriaContable::create([
'reporte_contable_id' => $balanceGeneral->id,
'nombre' => $categoria['nombre'],
'orden' => $categoria['orden'],
]);
}
// Estado de Resultados
$estadoResultados = ReporteContable::create(['nombre' => 'Estado de Resultados']);
$categoriasResultados = [
['nombre' => 'Ingresos', 'orden' => 1],
['nombre' => 'Costo de Venta', 'orden' => 2],
['nombre' => 'Gastos Operativos', 'orden' => 3],
['nombre' => 'Otros Gastos', 'orden' => 4],
['nombre' => 'Gastos Financieros', 'orden' => 5],
];
foreach ($categoriasResultados as $categoria) {
CategoriaContable::create([
'reporte_contable_id' => $estadoResultados->id,
'nombre' => $categoria['nombre'],
'orden' => $categoria['orden'],
]);
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Database\Seeders;
use App\Models\Umbral;
use Illuminate\Database\Seeder;
class UmbralesSeeder extends Seeder
{
public function run(): void
{
// Umbrales por defecto (sin giro específico)
$umbrales = [
// Márgenes (valores en decimales, ej: 0.20 = 20%)
['metrica' => 'margen_bruto', 'muy_positivo' => 0.50, 'positivo' => 0.35, 'neutral' => 0.20, 'negativo' => 0.10, 'muy_negativo' => 0],
['metrica' => 'margen_ebitda', 'muy_positivo' => 0.30, 'positivo' => 0.20, 'neutral' => 0.10, 'negativo' => 0.05, 'muy_negativo' => 0],
['metrica' => 'margen_operativo', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.08, 'negativo' => 0.03, 'muy_negativo' => 0],
['metrica' => 'margen_neto', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
['metrica' => 'margen_nopat', 'muy_positivo' => 0.18, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
['metrica' => 'margen_ocf', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.08, 'negativo' => 0.03, 'muy_negativo' => 0],
['metrica' => 'margen_fcf', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0, 'muy_negativo' => -0.10],
// Retorno
['metrica' => 'roic', 'muy_positivo' => 0.20, 'positivo' => 0.12, 'neutral' => 0.08, 'negativo' => 0.04, 'muy_negativo' => 0],
['metrica' => 'roe', 'muy_positivo' => 0.25, 'positivo' => 0.15, 'neutral' => 0.10, 'negativo' => 0.05, 'muy_negativo' => 0],
['metrica' => 'roa', 'muy_positivo' => 0.15, 'positivo' => 0.08, 'neutral' => 0.05, 'negativo' => 0.02, 'muy_negativo' => 0],
['metrica' => 'roce', 'muy_positivo' => 0.20, 'positivo' => 0.12, 'neutral' => 0.08, 'negativo' => 0.04, 'muy_negativo' => 0],
// Eficiencia (días)
['metrica' => 'dias_clientes', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
['metrica' => 'dias_proveedores', 'muy_positivo' => 60, 'positivo' => 45, 'neutral' => 30, 'negativo' => 20, 'muy_negativo' => 15],
['metrica' => 'dias_inventario', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
['metrica' => 'ciclo_conversion', 'muy_positivo' => 30, 'positivo' => 45, 'neutral' => 60, 'negativo' => 90, 'muy_negativo' => 120],
// Liquidez (ratios)
['metrica' => 'current_ratio', 'muy_positivo' => 2.5, 'positivo' => 2.0, 'neutral' => 1.5, 'negativo' => 1.0, 'muy_negativo' => 0.8],
['metrica' => 'quick_ratio', 'muy_positivo' => 1.5, 'positivo' => 1.2, 'neutral' => 1.0, 'negativo' => 0.8, 'muy_negativo' => 0.5],
['metrica' => 'cash_ratio', 'muy_positivo' => 0.5, 'positivo' => 0.3, 'neutral' => 0.2, 'negativo' => 0.1, 'muy_negativo' => 0.05],
// Solvencia
['metrica' => 'net_debt_ebitda', 'muy_positivo' => 1.0, 'positivo' => 2.0, 'neutral' => 3.0, 'negativo' => 4.0, 'muy_negativo' => 5.0],
['metrica' => 'interest_coverage', 'muy_positivo' => 10.0, 'positivo' => 5.0, 'neutral' => 3.0, 'negativo' => 2.0, 'muy_negativo' => 1.0],
['metrica' => 'debt_ratio', 'muy_positivo' => 0.30, 'positivo' => 0.40, 'neutral' => 0.50, 'negativo' => 0.60, 'muy_negativo' => 0.70],
// Gestión
['metrica' => 'revenue_growth', 'muy_positivo' => 0.20, 'positivo' => 0.10, 'neutral' => 0.05, 'negativo' => 0, 'muy_negativo' => -0.05],
['metrica' => 'capex_revenue', 'muy_positivo' => 0.05, 'positivo' => 0.08, 'neutral' => 0.12, 'negativo' => 0.15, 'muy_negativo' => 0.20],
['metrica' => 'effective_tax_rate', 'muy_positivo' => 0.25, 'positivo' => 0.28, 'neutral' => 0.30, 'negativo' => 0.33, 'muy_negativo' => 0.35],
];
foreach ($umbrales as $umbral) {
Umbral::create(array_merge($umbral, ['giro_id' => null]));
}
}
}

73
backend/routes/api.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ClienteController;
use App\Http\Controllers\BalanzaController;
use App\Http\Controllers\CuentaController;
use App\Http\Controllers\ReporteController;
use App\Http\Controllers\Admin\UsuarioController;
use App\Http\Controllers\Admin\UmbralController;
use App\Http\Controllers\Admin\ReglaMapeeoController;
use App\Http\Controllers\Admin\GiroController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
*/
// Rutas públicas
Route::post('/login', [AuthController::class, 'login']);
// Rutas protegidas
Route::middleware('auth:sanctum')->group(function () {
// Autenticación
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
// Giros (público para selects)
Route::get('/giros', [GiroController::class, 'activos']);
// Clientes
Route::apiResource('clientes', ClienteController::class);
// Balanzas (anidadas bajo clientes)
Route::get('/clientes/{cliente}/balanzas', [BalanzaController::class, 'index']);
Route::post('/clientes/{cliente}/balanzas', [BalanzaController::class, 'store']);
Route::get('/balanzas/{balanza}', [BalanzaController::class, 'show']);
Route::get('/balanzas/{balanza}/cuentas', [BalanzaController::class, 'cuentas']);
Route::put('/balanzas/{balanza}/exclusiones', [BalanzaController::class, 'updateExclusiones']);
// Cuentas
Route::put('/cuentas/{cuenta}/clasificacion', [CuentaController::class, 'updateClasificacion']);
Route::post('/cuentas/{cuenta}/toggle-exclusion', [CuentaController::class, 'toggleExclusion']);
Route::get('/anomalias', [CuentaController::class, 'anomalias']);
// Reportes
Route::get('/clientes/{cliente}/reportes', [ReporteController::class, 'index']);
Route::post('/clientes/{cliente}/reportes', [ReporteController::class, 'store']);
Route::get('/reportes/{reporte}', [ReporteController::class, 'show']);
Route::get('/reportes/{reporte}/pdf', [ReporteController::class, 'pdf']);
Route::delete('/reportes/{reporte}', [ReporteController::class, 'destroy']);
// Rutas de administración (solo admin)
Route::middleware('role:admin')->prefix('admin')->group(function () {
// Usuarios
Route::apiResource('usuarios', UsuarioController::class);
// Giros (CRUD completo)
Route::apiResource('giros', GiroController::class);
// Umbrales
Route::apiResource('umbrales', UmbralController::class);
Route::get('/umbrales/metrica/{metrica}/{giro_id?}', [UmbralController::class, 'porMetrica']);
// Reglas de mapeo
Route::apiResource('reglas-mapeo', ReglaMapeeoController::class);
// Registrar usuarios (solo admin)
Route::post('/register', [AuthController::class, 'register']);
});
});