commit 4c3dc94ff2ab2bb8c98b5ed5b298ea778efe49ac Author: Torch2196 Date: Sat Jan 31 22:24:00 2026 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde9e5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +/backend/vendor/ +/frontend/node_modules/ + +# Environment files +.env +.env.local +.env.*.local + +# Build outputs +/frontend/dist/ +/frontend/build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Laravel +/backend/storage/*.key +/backend/storage/framework/cache/* +/backend/storage/framework/sessions/* +/backend/storage/framework/views/* +/backend/storage/logs/* +/backend/bootstrap/cache/* +!/backend/storage/framework/.gitignore +!/backend/storage/framework/cache/.gitignore +!/backend/storage/framework/sessions/.gitignore +!/backend/storage/framework/views/.gitignore +!/backend/storage/logs/.gitignore +!/backend/bootstrap/cache/.gitignore + +# Testing +/backend/coverage/ +/frontend/coverage/ + +# Logs +*.log +npm-debug.log* + +# Misc +*.bak +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..652c49e --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Horux Strategy Platform + +Plataforma de Reportes Financieros automatizados a partir de balanzas de comprobación. + +## Descripción + +Sistema web que permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, Odoo, etc.) y obtener: +- Dashboard interactivo con métricas financieras +- Exportación a PDF profesional (32 páginas) +- Clasificación automática de cuentas +- Comparativos entre periodos +- Semáforos de rendimiento + +## Stack Tecnológico + +| Componente | Tecnología | +|------------|------------| +| Backend | Laravel 11 (API REST) | +| Frontend | React 18 + TypeScript + Vite | +| Autenticación | Laravel Sanctum | +| Base de datos | MySQL / PostgreSQL | +| Gráficas | Recharts | +| PDF | Browsershot (Puppeteer) | +| Estilos | Tailwind CSS | + +## Estructura del Proyecto + +``` +horux-strategy-platform/ +├── backend/ # Laravel 11 API +├── frontend/ # React + TypeScript +└── docs/ + └── plans/ # Documentación de diseño +``` + +## Instalación Rápida + +### Backend + +```bash +cd backend +composer install +cp .env.example .env +php artisan key:generate +# Configurar base de datos en .env +php artisan migrate --seed +php artisan serve +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +## Credenciales por defecto + +- **Email**: admin@horux360.com +- **Password**: password + +## Roles de Usuario + +| Rol | Permisos | +|-----|----------| +| **Admin** | Acceso total, configuración global | +| **Analista** | Gestionar clientes, procesar balanzas | +| **Cliente** | Ver dashboard propio, descargar PDFs | +| **Empleado** | Permisos configurables por cliente | + +## Sistemas Contables Soportados + +- CONTPAQi (implementado) +- Aspel +- SAP +- Odoo +- Alegra +- Parser genérico (CSV/Excel) + +## Métricas Calculadas + +- **Márgenes**: Bruto, EBITDA, Operativo, Neto, NOPAT, OCF, FCF +- **Retorno**: ROIC, ROE, ROA, ROCE +- **Eficiencia**: Asset Turnover, Días Clientes/Proveedores/Inventario +- **Liquidez**: Current Ratio, Quick Ratio, Cash Ratio +- **Solvencia**: Net Debt/EBITDA, Interest Coverage, Debt Ratio +- **Gestión**: Revenue Growth, CapEx/Revenue, Tax Rate + +## Documentación + +Ver `docs/plans/` para: +- Diseño del sistema +- Plan de implementación + +## Licencia + +Propietario - Horux 360 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..401beb1 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..065ca03 --- /dev/null +++ b/backend/README.md @@ -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 diff --git a/backend/app/Http/Controllers/Admin/GiroController.php b/backend/app/Http/Controllers/Admin/GiroController.php new file mode 100644 index 0000000..86e1daf --- /dev/null +++ b/backend/app/Http/Controllers/Admin/GiroController.php @@ -0,0 +1,62 @@ +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']); + } +} diff --git a/backend/app/Http/Controllers/Admin/ReglaMapeeoController.php b/backend/app/Http/Controllers/Admin/ReglaMapeeoController.php new file mode 100644 index 0000000..0a2e6ed --- /dev/null +++ b/backend/app/Http/Controllers/Admin/ReglaMapeeoController.php @@ -0,0 +1,71 @@ +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']); + } +} diff --git a/backend/app/Http/Controllers/Admin/UmbralController.php b/backend/app/Http/Controllers/Admin/UmbralController.php new file mode 100644 index 0000000..c9e9750 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/UmbralController.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Admin/UsuarioController.php b/backend/app/Http/Controllers/Admin/UsuarioController.php new file mode 100644 index 0000000..39c9630 --- /dev/null +++ b/backend/app/Http/Controllers/Admin/UsuarioController.php @@ -0,0 +1,71 @@ +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']); + } +} diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..28da9b1 --- /dev/null +++ b/backend/app/Http/Controllers/AuthController.php @@ -0,0 +1,74 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/BalanzaController.php b/backend/app/Http/Controllers/BalanzaController.php new file mode 100644 index 0000000..99e46e9 --- /dev/null +++ b/backend/app/Http/Controllers/BalanzaController.php @@ -0,0 +1,143 @@ +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']); + } +} diff --git a/backend/app/Http/Controllers/ClienteController.php b/backend/app/Http/Controllers/ClienteController.php new file mode 100644 index 0000000..37898eb --- /dev/null +++ b/backend/app/Http/Controllers/ClienteController.php @@ -0,0 +1,99 @@ +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']); + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..bd3e17e --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,12 @@ +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()); + } +} diff --git a/backend/app/Http/Controllers/ReporteController.php b/backend/app/Http/Controllers/ReporteController.php new file mode 100644 index 0000000..1187f60 --- /dev/null +++ b/backend/app/Http/Controllers/ReporteController.php @@ -0,0 +1,162 @@ +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'; + } +} diff --git a/backend/app/Http/Middleware/RoleMiddleware.php b/backend/app/Http/Middleware/RoleMiddleware.php new file mode 100644 index 0000000..8eaa519 --- /dev/null +++ b/backend/app/Http/Middleware/RoleMiddleware.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/backend/app/Models/Balanza.php b/backend/app/Models/Balanza.php new file mode 100644 index 0000000..211e1d8 --- /dev/null +++ b/backend/app/Models/Balanza.php @@ -0,0 +1,57 @@ + '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); + } +} diff --git a/backend/app/Models/CategoriaContable.php b/backend/app/Models/CategoriaContable.php new file mode 100644 index 0000000..3001c86 --- /dev/null +++ b/backend/app/Models/CategoriaContable.php @@ -0,0 +1,36 @@ +belongsTo(ReporteContable::class); + } + + public function cuentas(): HasMany + { + return $this->hasMany(Cuenta::class); + } + + public function reglasMapeoo(): HasMany + { + return $this->hasMany(ReglaMapeoo::class); + } +} diff --git a/backend/app/Models/Cliente.php b/backend/app/Models/Cliente.php new file mode 100644 index 0000000..a24afb8 --- /dev/null +++ b/backend/app/Models/Cliente.php @@ -0,0 +1,58 @@ + '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); + } +} diff --git a/backend/app/Models/Cuenta.php b/backend/app/Models/Cuenta.php new file mode 100644 index 0000000..dfca7f4 --- /dev/null +++ b/backend/app/Models/Cuenta.php @@ -0,0 +1,109 @@ + '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(); + } +} diff --git a/backend/app/Models/Giro.php b/backend/app/Models/Giro.php new file mode 100644 index 0000000..b5a75d7 --- /dev/null +++ b/backend/app/Models/Giro.php @@ -0,0 +1,34 @@ + 'boolean', + ]; + } + + public function clientes(): HasMany + { + return $this->hasMany(Cliente::class); + } + + public function umbrales(): HasMany + { + return $this->hasMany(Umbral::class); + } +} diff --git a/backend/app/Models/MapeoCuenta.php b/backend/app/Models/MapeoCuenta.php new file mode 100644 index 0000000..b1df4e6 --- /dev/null +++ b/backend/app/Models/MapeoCuenta.php @@ -0,0 +1,31 @@ +belongsTo(Cliente::class); + } + + public function categoriaContable(): BelongsTo + { + return $this->belongsTo(CategoriaContable::class); + } +} diff --git a/backend/app/Models/PermisoEmpleado.php b/backend/app/Models/PermisoEmpleado.php new file mode 100644 index 0000000..dffea4a --- /dev/null +++ b/backend/app/Models/PermisoEmpleado.php @@ -0,0 +1,42 @@ + '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 ?? []); + } +} diff --git a/backend/app/Models/ReglaMapeo.php b/backend/app/Models/ReglaMapeo.php new file mode 100644 index 0000000..97f1220 --- /dev/null +++ b/backend/app/Models/ReglaMapeo.php @@ -0,0 +1,73 @@ + '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); + } +} diff --git a/backend/app/Models/Reporte.php b/backend/app/Models/Reporte.php new file mode 100644 index 0000000..11178e2 --- /dev/null +++ b/backend/app/Models/Reporte.php @@ -0,0 +1,55 @@ + '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; + } +} diff --git a/backend/app/Models/ReporteContable.php b/backend/app/Models/ReporteContable.php new file mode 100644 index 0000000..d853e10 --- /dev/null +++ b/backend/app/Models/ReporteContable.php @@ -0,0 +1,28 @@ +hasMany(CategoriaContable::class)->orderBy('orden'); + } + + public function cuentas(): HasMany + { + return $this->hasMany(Cuenta::class); + } +} diff --git a/backend/app/Models/Umbral.php b/backend/app/Models/Umbral.php new file mode 100644 index 0000000..08d44d3 --- /dev/null +++ b/backend/app/Models/Umbral.php @@ -0,0 +1,57 @@ + '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'; + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php new file mode 100644 index 0000000..d034345 --- /dev/null +++ b/backend/app/Models/User.php @@ -0,0 +1,75 @@ + '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; + } +} diff --git a/backend/app/Services/CalculadorMetricas.php b/backend/app/Services/CalculadorMetricas.php new file mode 100644 index 0000000..c001759 --- /dev/null +++ b/backend/app/Services/CalculadorMetricas.php @@ -0,0 +1,419 @@ +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, + ], + ]; + } +} diff --git a/backend/app/Services/ClasificadorCuentas.php b/backend/app/Services/ClasificadorCuentas.php new file mode 100644 index 0000000..2751f73 --- /dev/null +++ b/backend/app/Services/ClasificadorCuentas.php @@ -0,0 +1,119 @@ +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]); + } + } + } + } +} diff --git a/backend/app/Services/GeneradorPdf.php b/backend/app/Services/GeneradorPdf.php new file mode 100644 index 0000000..861d680 --- /dev/null +++ b/backend/app/Services/GeneradorPdf.php @@ -0,0 +1,60 @@ +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)); + } +} diff --git a/backend/app/Services/Parsers/ContpaqiParser.php b/backend/app/Services/Parsers/ContpaqiParser.php new file mode 100644 index 0000000..b77e487 --- /dev/null +++ b/backend/app/Services/Parsers/ContpaqiParser.php @@ -0,0 +1,149 @@ += 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; + } + } + } + } +} diff --git a/backend/app/Services/Parsers/DetectorFormato.php b/backend/app/Services/Parsers/DetectorFormato.php new file mode 100644 index 0000000..08e2f00 --- /dev/null +++ b/backend/app/Services/Parsers/DetectorFormato.php @@ -0,0 +1,47 @@ +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]); + } +} diff --git a/backend/app/Services/Parsers/GenericoParser.php b/backend/app/Services/Parsers/GenericoParser.php new file mode 100644 index 0000000..a229091 --- /dev/null +++ b/backend/app/Services/Parsers/GenericoParser.php @@ -0,0 +1,180 @@ + ['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); + } +} diff --git a/backend/app/Services/Parsers/ParserInterface.php b/backend/app/Services/Parsers/ParserInterface.php new file mode 100644 index 0000000..11753ec --- /dev/null +++ b/backend/app/Services/Parsers/ParserInterface.php @@ -0,0 +1,40 @@ + + */ + 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; +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..cf0a1ae --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,26 @@ +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(); diff --git a/backend/composer.json b/backend/composer.json new file mode 100644 index 0000000..e92ea57 --- /dev/null +++ b/backend/composer.json @@ -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 +} diff --git a/backend/config/app.php b/backend/config/app.php new file mode 100644 index 0000000..8cd5cb6 --- /dev/null +++ b/backend/config/app.php @@ -0,0 +1,26 @@ + 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'), + ], + +]; diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..01f078c --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,32 @@ + ['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, + +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..0edb3a3 --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,59 @@ + 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, + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..7b7ccb7 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,53 @@ + 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, + ], + +]; diff --git a/backend/database/migrations/2025_01_31_000001_create_giros_table.php b/backend/database/migrations/2025_01_31_000001_create_giros_table.php new file mode 100644 index 0000000..ad4439e --- /dev/null +++ b/backend/database/migrations/2025_01_31_000001_create_giros_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('nombre'); + $table->boolean('activo')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('giros'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000002_create_clientes_table.php b/backend/database/migrations/2025_01_31_000002_create_clientes_table.php new file mode 100644 index 0000000..c57354b --- /dev/null +++ b/backend/database/migrations/2025_01_31_000002_create_clientes_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000003_create_users_table.php b/backend/database/migrations/2025_01_31_000003_create_users_table.php new file mode 100644 index 0000000..52c83ee --- /dev/null +++ b/backend/database/migrations/2025_01_31_000003_create_users_table.php @@ -0,0 +1,45 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000004_create_permisos_empleado_table.php b/backend/database/migrations/2025_01_31_000004_create_permisos_empleado_table.php new file mode 100644 index 0000000..2dce7bb --- /dev/null +++ b/backend/database/migrations/2025_01_31_000004_create_permisos_empleado_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000005_create_reportes_contables_table.php b/backend/database/migrations/2025_01_31_000005_create_reportes_contables_table.php new file mode 100644 index 0000000..15bae51 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000005_create_reportes_contables_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('nombre'); // "Balance General", "Estado de Resultados" + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('reportes_contables'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000006_create_categorias_contables_table.php b/backend/database/migrations/2025_01_31_000006_create_categorias_contables_table.php new file mode 100644 index 0000000..a594d82 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000006_create_categorias_contables_table.php @@ -0,0 +1,24 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000007_create_umbrales_table.php b/backend/database/migrations/2025_01_31_000007_create_umbrales_table.php new file mode 100644 index 0000000..23020c5 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000007_create_umbrales_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000008_create_balanzas_table.php b/backend/database/migrations/2025_01_31_000008_create_balanzas_table.php new file mode 100644 index 0000000..3e99424 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000008_create_balanzas_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000009_create_cuentas_table.php b/backend/database/migrations/2025_01_31_000009_create_cuentas_table.php new file mode 100644 index 0000000..0853f8a --- /dev/null +++ b/backend/database/migrations/2025_01_31_000009_create_cuentas_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000010_create_reglas_mapeo_table.php b/backend/database/migrations/2025_01_31_000010_create_reglas_mapeo_table.php new file mode 100644 index 0000000..856b99e --- /dev/null +++ b/backend/database/migrations/2025_01_31_000010_create_reglas_mapeo_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000011_create_mapeo_cuentas_table.php b/backend/database/migrations/2025_01_31_000011_create_mapeo_cuentas_table.php new file mode 100644 index 0000000..c14a9fb --- /dev/null +++ b/backend/database/migrations/2025_01_31_000011_create_mapeo_cuentas_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000012_create_reportes_table.php b/backend/database/migrations/2025_01_31_000012_create_reportes_table.php new file mode 100644 index 0000000..a7c0f56 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000012_create_reportes_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_01_31_000013_create_personal_access_tokens_table.php b/backend/database/migrations/2025_01_31_000013_create_personal_access_tokens_table.php new file mode 100644 index 0000000..b30c323 --- /dev/null +++ b/backend/database/migrations/2025_01_31_000013_create_personal_access_tokens_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/backend/database/seeders/AdminUserSeeder.php b/backend/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..bdb0ec2 --- /dev/null +++ b/backend/database/seeders/AdminUserSeeder.php @@ -0,0 +1,21 @@ + 'Administrador', + 'email' => 'admin@horux360.com', + 'password' => Hash::make('password'), + 'role' => 'admin', + 'cliente_id' => null, + ]); + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..12886bb --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,19 @@ +call([ + GirosSeeder::class, + ReportesContablesSeeder::class, + ReglasMapeeoContpaqiSeeder::class, + UmbralesSeeder::class, + AdminUserSeeder::class, + ]); + } +} diff --git a/backend/database/seeders/GirosSeeder.php b/backend/database/seeders/GirosSeeder.php new file mode 100644 index 0000000..89a55db --- /dev/null +++ b/backend/database/seeders/GirosSeeder.php @@ -0,0 +1,43 @@ + $nombre, 'activo' => true]); + } + } +} diff --git a/backend/database/seeders/ReglasMapeeoContpaqiSeeder.php b/backend/database/seeders/ReglasMapeeoContpaqiSeeder.php new file mode 100644 index 0000000..7ed48f7 --- /dev/null +++ b/backend/database/seeders/ReglasMapeeoContpaqiSeeder.php @@ -0,0 +1,142 @@ +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])); + } + } +} diff --git a/backend/database/seeders/ReportesContablesSeeder.php b/backend/database/seeders/ReportesContablesSeeder.php new file mode 100644 index 0000000..8a23e03 --- /dev/null +++ b/backend/database/seeders/ReportesContablesSeeder.php @@ -0,0 +1,53 @@ + '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'], + ]); + } + } +} diff --git a/backend/database/seeders/UmbralesSeeder.php b/backend/database/seeders/UmbralesSeeder.php new file mode 100644 index 0000000..0d639c9 --- /dev/null +++ b/backend/database/seeders/UmbralesSeeder.php @@ -0,0 +1,55 @@ + '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])); + } + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..a7cc813 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,73 @@ +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']); + }); +}); diff --git a/docs/01-descripcion-general.md b/docs/01-descripcion-general.md new file mode 100644 index 0000000..327cb9c --- /dev/null +++ b/docs/01-descripcion-general.md @@ -0,0 +1,86 @@ +# 1. Descripción General + +## ¿Qué es Horux Strategy? + +Horux Strategy es una plataforma web para generar reportes financieros automatizados a partir de balanzas de comprobación. Permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, Odoo, etc.) y obtener: + +- Dashboard interactivo con métricas financieras +- Exportación a PDF profesional (32 páginas) +- Clasificación automática de cuentas contables +- Comparativos entre periodos +- Semáforos de rendimiento (indicadores de salud financiera) + +## Características Principales + +### Para Analistas +- Gestión de múltiples clientes +- Subida y procesamiento de balanzas de comprobación +- Revisión y corrección de anomalías contables +- Generación de reportes financieros +- Exportación a PDF + +### Para Clientes +- Dashboard personalizado de su empresa +- Descarga de reportes en PDF +- Gestión de empleados y permisos + +### Para Administradores +- Gestión completa de usuarios +- Configuración de umbrales por industria +- Reglas de mapeo contable por sistema +- Catálogo de giros de negocio + +## Stack Tecnológico + +| Componente | Tecnología | +|------------|------------| +| Backend | Laravel 11 (PHP 8.2+) | +| Frontend | React 18 + TypeScript | +| Build Tool | Vite | +| Base de Datos | MySQL / PostgreSQL | +| Autenticación | Laravel Sanctum (tokens) | +| Gráficas | Recharts | +| Estilos | Tailwind CSS | +| PDF | Browsershot (Puppeteer) | +| Parsing PDF | spatie/pdf-to-text | +| Excel | PhpSpreadsheet | + +## Flujo de Trabajo + +``` +1. Crear Cliente + └── Nombre, logo, giro, moneda + +2. Subir Balanzas (2+ archivos) + └── PDF, Excel o CSV de diferentes periodos + +3. Detección Automática + └── Sistema identifica origen (CONTPAQi, Aspel, etc.) + +4. Clasificación Automática + └── Mapeo a categorías contables + +5. Revisión de Anomalías + └── Corregir cuentas mal clasificadas + +6. Limpieza de Cuentas + └── Excluir cuentas de estrategia fiscal + +7. Generación de Reporte + └── Cálculo de 27+ métricas + +8. Dashboard Interactivo + └── Visualización de resultados + +9. Exportar PDF + └── Reporte profesional de 32 páginas +``` + +## Roles de Usuario + +| Rol | Descripción | Permisos | +|-----|-------------|----------| +| **Admin** | Superusuario del sistema | Acceso total, configuración global, gestión de umbrales | +| **Analista** | Operador de la plataforma | Gestionar clientes asignados, procesar balanzas, generar reportes | +| **Cliente** | Usuario empresarial | Ver dashboard propio, descargar PDFs, gestionar empleados | +| **Empleado** | Colaborador del cliente | Permisos configurables por el cliente | diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md new file mode 100644 index 0000000..40da9a1 --- /dev/null +++ b/docs/02-arquitectura.md @@ -0,0 +1,169 @@ +# 2. Arquitectura del Sistema + +## Diagrama de Componentes + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND (React) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Login │ │Clientes │ │Dashboard│ │ Admin │ │ PdfView │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ └───────────┴───────────┴───────────┴───────────┘ │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ │ API Service │ │ +│ │ (Axios) │ │ +│ └─────────┬─────────┘ │ +└──────────────────────────────┼──────────────────────────────────┘ + │ HTTP/REST + ▼ +┌──────────────────────────────┴──────────────────────────────────┐ +│ BACKEND (Laravel) │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Controllers │ │ +│ │ Auth │ Cliente │ Balanza │ Cuenta │ Reporte │ Admin │ │ +│ └───────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴─────────────────────────────┐ │ +│ │ Services │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Parsers │ │ Clasificador │ │ Calculador │ │ │ +│ │ │ - CONTPAQi │ │ Cuentas │ │ Métricas │ │ │ +│ │ │ - Genérico │ │ │ │ │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ │ ┌──────────────┐ │ │ +│ │ │ Generador │ │ │ +│ │ │ PDF │ │ │ +│ │ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴─────────────────────────────┐ │ +│ │ Models │ │ +│ │ User │ Cliente │ Balanza │ Cuenta │ Reporte │ etc. │ │ +│ └───────────────────────────┬─────────────────────────────┘ │ +└──────────────────────────────┼──────────────────────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Database │ + │ MySQL/PostgreSQL│ + └──────────────────┘ +``` + +## Estructura de Directorios + +``` +horux-strategy-platform/ +├── backend/ # Laravel 11 API +│ ├── app/ +│ │ ├── Http/ +│ │ │ ├── Controllers/ +│ │ │ │ ├── AuthController.php +│ │ │ │ ├── ClienteController.php +│ │ │ │ ├── BalanzaController.php +│ │ │ │ ├── CuentaController.php +│ │ │ │ ├── ReporteController.php +│ │ │ │ └── Admin/ +│ │ │ │ ├── UsuarioController.php +│ │ │ │ ├── GiroController.php +│ │ │ │ ├── UmbralController.php +│ │ │ │ └── ReglaMapeeoController.php +│ │ │ └── Middleware/ +│ │ │ └── RoleMiddleware.php +│ │ ├── Models/ +│ │ │ ├── User.php +│ │ │ ├── Cliente.php +│ │ │ ├── Giro.php +│ │ │ ├── Balanza.php +│ │ │ ├── Cuenta.php +│ │ │ ├── Reporte.php +│ │ │ ├── ReporteContable.php +│ │ │ ├── CategoriaContable.php +│ │ │ ├── Umbral.php +│ │ │ ├── ReglaMapeo.php +│ │ │ ├── MapeoCuenta.php +│ │ │ └── PermisoEmpleado.php +│ │ └── Services/ +│ │ ├── Parsers/ +│ │ │ ├── ParserInterface.php +│ │ │ ├── DetectorFormato.php +│ │ │ ├── ContpaqiParser.php +│ │ │ └── GenericoParser.php +│ │ ├── ClasificadorCuentas.php +│ │ ├── CalculadorMetricas.php +│ │ └── GeneradorPdf.php +│ ├── database/ +│ │ ├── migrations/ +│ │ └── seeders/ +│ ├── routes/ +│ │ └── api.php +│ └── config/ +│ +├── frontend/ # React + TypeScript +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── charts/ +│ │ │ │ ├── BarChart.tsx +│ │ │ │ └── LineChart.tsx +│ │ │ ├── cards/ +│ │ │ │ ├── KPICard.tsx +│ │ │ │ └── MetricTable.tsx +│ │ │ ├── forms/ +│ │ │ │ ├── ClienteForm.tsx +│ │ │ │ ├── UploadBalanza.tsx +│ │ │ │ └── GenerarReporte.tsx +│ │ │ └── layout/ +│ │ │ ├── Layout.tsx +│ │ │ ├── Sidebar.tsx +│ │ │ └── Header.tsx +│ │ ├── pages/ +│ │ │ ├── Login.tsx +│ │ │ ├── Clientes/ +│ │ │ ├── Dashboard/ +│ │ │ ├── PdfView/ +│ │ │ └── Admin/ +│ │ ├── context/ +│ │ │ └── AuthContext.tsx +│ │ ├── services/ +│ │ │ └── api.ts +│ │ ├── types/ +│ │ │ └── index.ts +│ │ └── hooks/ +│ └── public/ +│ +└── docs/ # Documentación + └── plans/ +``` + +## Flujo de Datos + +### Subida de Balanza +``` +1. Usuario sube archivo (PDF/Excel/CSV) +2. DetectorFormato identifica sistema origen +3. Parser específico extrae datos +4. ClasificadorCuentas aplica reglas de mapeo +5. Cuentas se guardan en BD con clasificación +6. Cuentas con anomalías se marcan para revisión +``` + +### Generación de Reporte +``` +1. Usuario selecciona balanzas a incluir +2. CalculadorMetricas procesa datos +3. Se calculan estados financieros +4. Se calculan 27+ métricas +5. Se evalúan umbrales para semáforos +6. Se guardan resultados en JSON +7. Dashboard muestra resultados +``` + +### Exportación PDF +``` +1. Usuario solicita PDF +2. GeneradorPdf prepara URL con token +3. Browsershot renderiza PdfView de React +4. Se genera PDF de 32 páginas +5. Se guarda en storage +6. Se descarga al usuario +``` diff --git a/docs/03-instalacion.md b/docs/03-instalacion.md new file mode 100644 index 0000000..076407a --- /dev/null +++ b/docs/03-instalacion.md @@ -0,0 +1,205 @@ +# 3. Instalación y Configuración + +## Requisitos del Sistema + +### Backend +- PHP 8.2 o superior +- Composer 2.x +- MySQL 8.0+ o PostgreSQL 14+ +- Extensiones PHP: pdo_mysql, openssl, mbstring, fileinfo, gd + +### Frontend +- Node.js 18 o superior +- npm o yarn + +### Para generación de PDF +- Node.js (para Puppeteer) +- Chromium (se instala automáticamente) + +--- + +## Instalación en Windows + +### Opción 1: Usando Laragon (Recomendado) + +1. **Descargar Laragon** + - Visita https://laragon.org/download/ + - Descarga la versión "Full" (incluye PHP, MySQL, Composer) + - Instala y ejecuta Laragon + +2. **Configurar el proyecto** + ```bash + # Copia el proyecto a C:\laragon\www\horux-strategy-platform + + # Abre la terminal de Laragon (clic derecho > Terminal) + cd horux-strategy-platform\backend + composer install + copy .env.example .env + php artisan key:generate + ``` + +3. **Crear base de datos** + - En Laragon, clic en "Database" > "MySQL" + - Crea una base de datos llamada `horux_strategy` + +4. **Ejecutar migraciones** + ```bash + php artisan migrate --seed + ``` + +5. **Iniciar backend** + ```bash + php artisan serve + ``` + +6. **Iniciar frontend** (otra terminal) + ```bash + cd horux-strategy-platform\frontend + npm install + npm run dev + ``` + +### Opción 2: Instalación Manual + +1. **Instalar PHP** + - Descarga de https://windows.php.net/download/ + - Versión: VS16 x64 Thread Safe + - Extrae en `C:\php` + - Agrega `C:\php` al PATH del sistema + - Copia `php.ini-development` a `php.ini` + - Edita `php.ini` y habilita: + ```ini + extension=pdo_mysql + extension=openssl + extension=mbstring + extension=fileinfo + extension=gd + ``` + +2. **Instalar Composer** + - Descarga de https://getcomposer.org/download/ + - Ejecuta el instalador + +3. **Instalar MySQL** + - Descarga de https://dev.mysql.com/downloads/mysql/ + - Durante instalación, anota usuario y contraseña + +4. **Configurar proyecto** (igual que Opción 1, pasos 2-6) + +--- + +## Instalación en Linux/Mac + +```bash +# Clonar o copiar el proyecto +cd horux-strategy-platform + +# Backend +cd backend +composer install +cp .env.example .env +php artisan key:generate + +# Configurar .env con datos de MySQL +nano .env + +# Migraciones +php artisan migrate --seed +php artisan serve + +# Frontend (otra terminal) +cd ../frontend +npm install +npm run dev +``` + +--- + +## Configuración del Archivo .env + +```env +# Aplicación +APP_NAME="Horux Strategy" +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 +FRONTEND_URL=http://localhost:5173 + +# Base de Datos +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=horux_strategy +DB_USERNAME=root +DB_PASSWORD=tu_password + +# Sanctum (autenticación) +SANCTUM_STATEFUL_DOMAINS=localhost:5173,127.0.0.1:5173 +``` + +--- + +## Verificación de Instalación + +### Backend +```bash +# Verificar que el servidor responde +curl http://localhost:8000/api/giros +# Debe retornar lista de giros en JSON +``` + +### Frontend +```bash +# Abrir en navegador +http://localhost:5173 +# Debe mostrar página de login +``` + +### Credenciales de Prueba +- **Email**: admin@horux360.com +- **Password**: password + +--- + +## Solución de Problemas + +### Error: "php" no es reconocido +- Verifica que PHP está en el PATH del sistema +- Reinicia la terminal + +### Error: SQLSTATE Connection refused +- Verifica que MySQL está corriendo +- Verifica credenciales en .env + +### Error: CORS +- Verifica FRONTEND_URL en .env +- Verifica config/cors.php + +### Error: 419 Page Expired +- Ejecuta `php artisan config:clear` +- Verifica SANCTUM_STATEFUL_DOMAINS + +--- + +## Despliegue en Producción + +### Backend +```bash +composer install --optimize-autoloader --no-dev +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +### Frontend +```bash +npm run build +# Servir carpeta dist/ con nginx/apache +``` + +### Variables de Producción +```env +APP_ENV=production +APP_DEBUG=false +FRONTEND_URL=https://tu-dominio.com +``` diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md new file mode 100644 index 0000000..495e6fb --- /dev/null +++ b/docs/04-api-reference.md @@ -0,0 +1,322 @@ +# 4. API Reference + +## Base URL +``` +http://localhost:8000/api +``` + +## Autenticación + +Todos los endpoints (excepto login) requieren token Bearer: +``` +Authorization: Bearer {token} +``` + +--- + +## Endpoints de Autenticación + +### POST /login +Iniciar sesión + +**Request:** +```json +{ + "email": "admin@horux360.com", + "password": "password" +} +``` + +**Response 200:** +```json +{ + "user": { + "id": 1, + "nombre": "Administrador", + "email": "admin@horux360.com", + "role": "admin", + "cliente_id": null + }, + "token": "1|abc123..." +} +``` + +### POST /logout +Cerrar sesión (requiere autenticación) + +**Response 200:** +```json +{ + "message": "Sesión cerrada exitosamente" +} +``` + +### GET /user +Obtener usuario actual + +**Response 200:** +```json +{ + "id": 1, + "nombre": "Administrador", + "email": "admin@horux360.com", + "role": "admin", + "cliente_id": null, + "cliente": null +} +``` + +--- + +## Endpoints de Giros + +### GET /giros +Listar giros activos + +**Response 200:** +```json +[ + { "id": 1, "nombre": "Hotelería", "activo": true }, + { "id": 2, "nombre": "Comercio", "activo": true } +] +``` + +--- + +## Endpoints de Clientes + +### GET /clientes +Listar clientes (filtrado por rol) + +**Response 200:** +```json +[ + { + "id": 1, + "nombre_empresa": "Hotel Boutique HSA", + "logo": "logos/abc123.png", + "giro_id": 1, + "moneda": "MXN", + "giro": { "id": 1, "nombre": "Hotelería" } + } +] +``` + +### POST /clientes +Crear cliente (multipart/form-data) + +**Request:** +``` +nombre_empresa: Hotel Ejemplo +giro_id: 1 +moneda: MXN +logo: [archivo imagen] +``` + +**Response 201:** +```json +{ + "id": 2, + "nombre_empresa": "Hotel Ejemplo", + "giro_id": 1, + "moneda": "MXN", + "logo": "logos/xyz789.png" +} +``` + +### GET /clientes/{id} +Obtener cliente con balanzas y reportes + +### PUT /clientes/{id} +Actualizar cliente + +### DELETE /clientes/{id} +Eliminar cliente (solo admin) + +--- + +## Endpoints de Balanzas + +### GET /clientes/{id}/balanzas +Listar balanzas de un cliente + +**Response 200:** +```json +[ + { + "id": 1, + "cliente_id": 1, + "periodo_inicio": "2024-01-01", + "periodo_fin": "2024-12-31", + "sistema_origen": "contpaqi", + "status": "completado" + } +] +``` + +### POST /clientes/{id}/balanzas +Subir balanza (multipart/form-data) + +**Request:** +``` +archivo: [archivo PDF/Excel/CSV] +periodo_inicio: 2024-01-01 +periodo_fin: 2024-12-31 +``` + +**Response 201:** +```json +{ + "id": 2, + "sistema_origen": "contpaqi", + "status": "procesando" +} +``` + +### GET /balanzas/{id}/cuentas +Listar cuentas de una balanza + +**Response 200:** +```json +[ + { + "id": 1, + "codigo": "001-100-000", + "nombre": "ACTIVO CIRCULANTE", + "nivel": 1, + "saldo_final_deudor": 1500000.00, + "saldo_final_acreedor": 0, + "excluida": false, + "es_cuenta_padre": true, + "requiere_revision": false, + "categoria_contable": { + "id": 1, + "nombre": "Activos Circulantes" + } + } +] +``` + +### PUT /balanzas/{id}/exclusiones +Actualizar cuentas excluidas + +**Request:** +```json +{ + "exclusiones": [5, 12, 23] +} +``` + +--- + +## Endpoints de Cuentas + +### PUT /cuentas/{id}/clasificacion +Corregir clasificación de cuenta + +**Request:** +```json +{ + "reporte_contable_id": 1, + "categoria_contable_id": 3, + "requiere_revision": false +} +``` + +### POST /cuentas/{id}/toggle-exclusion +Alternar exclusión de cuenta + +### GET /anomalias +Listar cuentas que requieren revisión + +--- + +## Endpoints de Reportes + +### GET /clientes/{id}/reportes +Listar reportes de un cliente + +### POST /clientes/{id}/reportes +Generar nuevo reporte + +**Request:** +```json +{ + "nombre": "Reporte Anual 2024", + "balanza_ids": [1, 2, 3] +} +``` + +**Response 201:** +```json +{ + "id": 1, + "nombre": "Reporte Anual 2024", + "periodo_tipo": "anual", + "status": "completado", + "data_calculada": { ... } +} +``` + +### GET /reportes/{id} +Obtener reporte con datos calculados + +### GET /reportes/{id}/pdf +Descargar PDF del reporte + +**Response:** Archivo PDF + +### DELETE /reportes/{id} +Eliminar reporte + +--- + +## Endpoints de Administración + +Requieren rol `admin`. + +### Usuarios +- `GET /admin/usuarios` - Listar usuarios +- `POST /admin/usuarios` - Crear usuario +- `PUT /admin/usuarios/{id}` - Actualizar usuario +- `DELETE /admin/usuarios/{id}` - Eliminar usuario + +### Giros +- `GET /admin/giros` - Listar todos los giros +- `POST /admin/giros` - Crear giro +- `PUT /admin/giros/{id}` - Actualizar giro +- `DELETE /admin/giros/{id}` - Eliminar giro + +### Umbrales +- `GET /admin/umbrales` - Listar umbrales +- `POST /admin/umbrales` - Crear umbral +- `PUT /admin/umbrales/{id}` - Actualizar umbral +- `DELETE /admin/umbrales/{id}` - Eliminar umbral + +### Reglas de Mapeo +- `GET /admin/reglas-mapeo` - Listar reglas +- `POST /admin/reglas-mapeo` - Crear regla +- `PUT /admin/reglas-mapeo/{id}` - Actualizar regla +- `DELETE /admin/reglas-mapeo/{id}` - Eliminar regla + +--- + +## Códigos de Error + +| Código | Descripción | +|--------|-------------| +| 401 | No autenticado | +| 403 | No autorizado | +| 404 | Recurso no encontrado | +| 422 | Error de validación | +| 500 | Error del servidor | + +**Ejemplo error 422:** +```json +{ + "message": "The email field is required.", + "errors": { + "email": ["The email field is required."] + } +} +``` diff --git a/docs/05-frontend.md b/docs/05-frontend.md new file mode 100644 index 0000000..d10ecba --- /dev/null +++ b/docs/05-frontend.md @@ -0,0 +1,271 @@ +# 5. Guía del Frontend + +## Stack Tecnológico + +- **React 18** - Biblioteca de UI +- **TypeScript** - Tipado estático +- **Vite** - Build tool y dev server +- **React Router** - Navegación +- **Axios** - Cliente HTTP +- **Recharts** - Gráficas +- **Tailwind CSS** - Estilos +- **react-hot-toast** - Notificaciones +- **react-dropzone** - Upload de archivos + +--- + +## Estructura de Carpetas + +``` +frontend/src/ +├── components/ +│ ├── charts/ # Componentes de gráficas +│ │ ├── BarChart.tsx +│ │ └── LineChart.tsx +│ ├── cards/ # Tarjetas y tablas +│ │ ├── KPICard.tsx +│ │ └── MetricTable.tsx +│ ├── forms/ # Formularios +│ │ ├── ClienteForm.tsx +│ │ ├── UploadBalanza.tsx +│ │ └── GenerarReporte.tsx +│ └── layout/ # Estructura de página +│ ├── Layout.tsx +│ ├── Sidebar.tsx +│ └── Header.tsx +├── pages/ +│ ├── Login.tsx +│ ├── Clientes/ +│ │ ├── ClientesList.tsx +│ │ └── ClienteDetail.tsx +│ ├── Dashboard/ +│ │ └── index.tsx +│ ├── PdfView/ +│ │ └── index.tsx +│ └── Admin/ +│ ├── Usuarios.tsx +│ ├── Giros.tsx +│ ├── Umbrales.tsx +│ └── ReglasMapeeo.tsx +├── context/ +│ └── AuthContext.tsx +├── services/ +│ └── api.ts +├── types/ +│ └── index.ts +└── hooks/ +``` + +--- + +## Contexto de Autenticación + +```tsx +// Uso del contexto +import { useAuth } from '../context/AuthContext'; + +function MyComponent() { + const { user, login, logout, isAdmin, isAnalista } = useAuth(); + + if (!user) return ; + + return
Hola {user.nombre}
; +} +``` + +### Propiedades disponibles: +- `user` - Usuario actual o null +- `loading` - Estado de carga inicial +- `login(email, password)` - Iniciar sesión +- `logout()` - Cerrar sesión +- `isAdmin` - Es administrador +- `isAnalista` - Es analista +- `isCliente` - Es cliente +- `isEmpleado` - Es empleado + +--- + +## Servicio API + +```tsx +import { clientesApi, balanzasApi, reportesApi } from '../services/api'; + +// Listar clientes +const clientes = await clientesApi.list(); + +// Crear cliente +const formData = new FormData(); +formData.append('nombre_empresa', 'Mi Empresa'); +formData.append('giro_id', '1'); +const cliente = await clientesApi.create(formData); + +// Subir balanza +const balanza = await balanzasApi.upload(clienteId, formData); + +// Generar reporte +const reporte = await reportesApi.create(clienteId, 'Reporte 2024', [1, 2]); + +// Descargar PDF +const blob = await reportesApi.downloadPdf(reporteId); +``` + +--- + +## Tipos TypeScript + +```tsx +// Principales tipos disponibles +interface User { + id: number; + nombre: string; + email: string; + role: 'admin' | 'analista' | 'cliente' | 'empleado'; + cliente_id: number | null; +} + +interface Cliente { + id: number; + nombre_empresa: string; + logo: string | null; + giro_id: number; + moneda: string; + giro?: Giro; +} + +interface Balanza { + id: number; + cliente_id: number; + periodo_inicio: string; + periodo_fin: string; + sistema_origen: string; + status: 'pendiente' | 'procesando' | 'completado' | 'error'; +} + +interface Reporte { + id: number; + nombre: string; + periodo_tipo: 'mensual' | 'trimestral' | 'anual'; + data_calculada: DataCalculada | null; + status: string; +} + +type Tendencia = 'muy_positivo' | 'positivo' | 'neutral' | 'negativo' | 'muy_negativo'; +``` + +--- + +## Componentes Reutilizables + +### KPICard +```tsx + +``` + +### MetricTable +```tsx + +``` + +### BarChart +```tsx + +``` + +### LineChart +```tsx + ({ + periodo: p.periodo, + 'Margen Bruto': p.margen_bruto * 100, + 'Margen Neto': p.margen_neto * 100, + }))} + lines={['Margen Bruto', 'Margen Neto']} +/> +``` + +--- + +## Estilos con Tailwind + +### Clases personalizadas (index.css) +```css +.btn { @apply px-4 py-2 rounded-lg font-medium transition-colors; } +.btn-primary { @apply bg-primary-600 text-white hover:bg-primary-700; } +.btn-secondary { @apply bg-gray-200 text-gray-800 hover:bg-gray-300; } +.input { @apply w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500; } +.card { @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6; } +.label { @apply block text-sm font-medium text-gray-700 mb-1; } +``` + +### Colores de marca (tailwind.config.js) +```js +colors: { + horux: { + dark: '#1a1a2e', + primary: '#16213e', + accent: '#0f3460', + highlight: '#e94560', + }, + status: { + 'muy-positivo': '#10b981', + 'positivo': '#34d399', + 'neutral': '#fbbf24', + 'negativo': '#f97316', + 'muy-negativo': '#ef4444', + }, +} +``` + +--- + +## Rutas + +| Ruta | Componente | Rol requerido | +|------|------------|---------------| +| `/login` | Login | Público | +| `/clientes` | ClientesList | Autenticado | +| `/clientes/:id` | ClienteDetail | Autenticado | +| `/dashboard/:clienteId/:reporteId` | Dashboard | Autenticado | +| `/pdf-view/:id` | PdfView | Token especial | +| `/admin/usuarios` | AdminUsuarios | Admin | +| `/admin/giros` | AdminGiros | Admin | +| `/admin/umbrales` | AdminUmbrales | Admin | +| `/admin/reglas-mapeo` | AdminReglasMapeeo | Admin | + +--- + +## Desarrollo + +```bash +# Instalar dependencias +npm install + +# Servidor de desarrollo +npm run dev + +# Build para producción +npm run build + +# Preview del build +npm run preview + +# Linting +npm run lint +``` diff --git a/docs/06-base-de-datos.md b/docs/06-base-de-datos.md new file mode 100644 index 0000000..1a79cdd --- /dev/null +++ b/docs/06-base-de-datos.md @@ -0,0 +1,241 @@ +# 6. Base de Datos + +## Diagrama Entidad-Relación + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ giros │ │ clientes │ │ users │ +├─────────────┤ ├─────────────┤ ├─────────────┤ +│ id │◄──────┤ giro_id │ │ id │ +│ nombre │ │ id │◄──────┤ cliente_id │ +│ activo │ │ nombre_emp │ │ nombre │ +└─────────────┘ │ logo │ │ email │ + │ moneda │ │ password │ + │ config │ │ role │ + └──────┬──────┘ └─────────────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌─────────────┐┌─────────────┐┌─────────────┐ + │ balanzas ││ reportes ││mapeo_cuentas│ + ├─────────────┤├─────────────┤├─────────────┤ + │ id ││ id ││ id │ + │ cliente_id ││ cliente_id ││ cliente_id │ + │ periodo_ini ││ nombre ││ codigo_pat │ + │ periodo_fin ││ periodo_tipo││ categoria_id│ + │ sistema_orig││ data_calc │└─────────────┘ + │ status ││ pdf_path │ + └──────┬──────┘└─────────────┘ + │ + ▼ + ┌─────────────┐ + │ cuentas │ + ├─────────────┤ + │ id │ + │ balanza_id │ + │ codigo │ + │ nombre │ + │ nivel │ + │ saldos... │ + │ excluida │ + │ categoria_id│ + └─────────────┘ + +┌─────────────────┐ ┌─────────────────┐ +│reportes_contable│ │categorias_contab│ +├─────────────────┤ ├─────────────────┤ +│ id │◄────┤ reporte_cont_id │ +│ nombre │ │ id │ +└─────────────────┘ │ nombre │ + │ orden │ + └────────┬────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │reglas_mapeo │ │ umbrales │ + ├─────────────┤ ├─────────────┤ + │ sistema_orig│ │ metrica │ + │ rango_ini │ │ muy_positivo│ + │ rango_fin │ │ positivo │ + │ categoria_id│ │ neutral │ + │ prioridad │ │ negativo │ + └─────────────┘ │ muy_negativo│ + │ giro_id │ + └─────────────┘ +``` + +--- + +## Tablas + +### users +Usuarios del sistema. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| nombre | varchar(255) | Nombre completo | +| email | varchar(255) | Email único | +| password | varchar(255) | Hash de contraseña | +| role | enum | admin, analista, cliente, empleado | +| cliente_id | bigint | FK a clientes (nullable) | + +### clientes +Empresas registradas. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| nombre_empresa | varchar(255) | Nombre de la empresa | +| logo | varchar(255) | Path al logo | +| giro_id | bigint | FK a giros | +| moneda | varchar(3) | MXN, USD, EUR | +| configuracion | json | Configuración adicional | + +### giros +Catálogo de giros de negocio. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| nombre | varchar(255) | Nombre del giro | +| activo | boolean | Estado | + +### balanzas +Archivos de balanza subidos. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| cliente_id | bigint | FK a clientes | +| periodo_inicio | date | Inicio del periodo | +| periodo_fin | date | Fin del periodo | +| sistema_origen | varchar(50) | contpaqi, aspel, etc. | +| archivo_original | varchar(255) | Path al archivo | +| status | enum | pendiente, procesando, completado, error | +| error_mensaje | text | Mensaje de error si aplica | + +### cuentas +Cuentas extraídas de balanzas. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| balanza_id | bigint | FK a balanzas | +| codigo | varchar(20) | Código de cuenta (ej: 001-100-000) | +| nombre | varchar(255) | Nombre de la cuenta | +| nivel | int | Nivel jerárquico (1, 2, 3) | +| reporte_contable_id | bigint | FK a reportes_contables | +| categoria_contable_id | bigint | FK a categorias_contables | +| cuenta_padre_id | bigint | FK a cuentas (self-reference) | +| saldo_inicial_deudor | decimal(18,2) | | +| saldo_inicial_acreedor | decimal(18,2) | | +| cargos | decimal(18,2) | | +| abonos | decimal(18,2) | | +| saldo_final_deudor | decimal(18,2) | | +| saldo_final_acreedor | decimal(18,2) | | +| excluida | boolean | Excluida del cálculo | +| es_cuenta_padre | boolean | Es cuenta padre | +| requiere_revision | boolean | Necesita revisión manual | +| nota_revision | text | Nota sobre la anomalía | + +### reportes_contables +Tipos de reporte (Balance, Estado de Resultados). + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| nombre | varchar(255) | "Balance General", "Estado de Resultados" | + +### categorias_contables +Categorías dentro de cada reporte. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| reporte_contable_id | bigint | FK a reportes_contables | +| nombre | varchar(255) | Nombre de la categoría | +| orden | int | Orden de aparición | + +**Categorías de Balance General:** +1. Activos Circulantes +2. Activos No Circulantes +3. Pasivo Circulante +4. Pasivo No Circulante +5. Capital Social +6. Pérdidas Ejercicios Anteriores +7. Utilidades Ejercicios Anteriores + +**Categorías de Estado de Resultados:** +1. Ingresos +2. Costo de Venta +3. Gastos Operativos +4. Otros Gastos +5. Gastos Financieros + +### reglas_mapeo +Reglas para clasificar cuentas automáticamente. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| sistema_origen | varchar(50) | contpaqi, aspel, etc. | +| cuenta_padre_codigo | varchar(20) | Código de cuenta padre | +| rango_inicio | varchar(20) | Inicio del rango de códigos | +| rango_fin | varchar(20) | Fin del rango | +| patron_regex | varchar(255) | Patrón regex alternativo | +| reporte_contable_id | bigint | FK | +| categoria_contable_id | bigint | FK | +| prioridad | int | Mayor = se evalúa primero | +| activo | boolean | Regla activa | + +### umbrales +Umbrales para semáforos de métricas. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| metrica | varchar(100) | Nombre de la métrica | +| muy_positivo | decimal(10,4) | Umbral muy positivo | +| positivo | decimal(10,4) | Umbral positivo | +| neutral | decimal(10,4) | Umbral neutral | +| negativo | decimal(10,4) | Umbral negativo | +| muy_negativo | decimal(10,4) | Umbral muy negativo | +| giro_id | bigint | FK a giros (nullable = general) | + +### reportes +Reportes generados. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | bigint | PK | +| cliente_id | bigint | FK a clientes | +| nombre | varchar(255) | Nombre del reporte | +| periodo_tipo | enum | mensual, trimestral, anual | +| periodo_inicio | date | | +| periodo_fin | date | | +| fecha_generacion | timestamp | | +| data_calculada | json | Métricas y estados financieros | +| pdf_path | varchar(255) | Path al PDF generado | +| status | enum | pendiente, procesando, completado, error | + +### reporte_balanza +Tabla pivote reporte-balanza. + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| reporte_id | bigint | FK a reportes | +| balanza_id | bigint | FK a balanzas | + +--- + +## Seeders + +Los seeders iniciales crean: + +1. **GirosSeeder** - 24 giros de negocio +2. **ReportesContablesSeeder** - 2 reportes + 12 categorías +3. **ReglasMapeeoContpaqiSeeder** - 12 reglas para CONTPAQi +4. **UmbralesSeeder** - 24 umbrales por defecto +5. **AdminUserSeeder** - Usuario admin inicial diff --git a/docs/07-parsers.md b/docs/07-parsers.md new file mode 100644 index 0000000..c49aa04 --- /dev/null +++ b/docs/07-parsers.md @@ -0,0 +1,253 @@ +# 7. Parsers de Balanzas + +## Arquitectura + +``` +Upload archivo + │ + ▼ +┌─────────────────┐ +│ DetectorFormato │ ← Detecta el sistema origen +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Parser Específico│ +│ - ContpaqiParser│ +│ - GenericoParser│ +│ - (futuro: Aspel)│ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Formato Standard│ ← Estructura normalizada +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ ClasificadorCtas│ ← Aplica reglas de mapeo +└─────────────────┘ +``` + +--- + +## Interface ParserInterface + +Todos los parsers deben implementar: + +```php +interface ParserInterface +{ + /** + * Parsea archivo y retorna array de cuentas normalizadas + */ + public function parsear(string $filePath): array; + + /** + * Verifica si puede manejar el archivo + */ + public function puedeManej(string $filePath): bool; + + /** + * Retorna identificador del sistema + */ + public function getSistema(): string; +} +``` + +### Estructura de cuenta normalizada: + +```php +[ + 'codigo' => '001-100-000', + 'nombre' => 'ACTIVO CIRCULANTE', + 'nivel' => 1, + 'saldo_inicial_deudor' => 1500000.00, + 'saldo_inicial_acreedor' => 0.00, + 'cargos' => 500000.00, + 'abonos' => 200000.00, + 'saldo_final_deudor' => 1800000.00, + 'saldo_final_acreedor' => 0.00, + 'es_cuenta_padre' => true, + 'cuenta_padre_codigo' => null, // opcional +] +``` + +--- + +## DetectorFormato + +Detecta automáticamente el sistema origen del archivo. + +```php +class DetectorFormato +{ + private array $parsers = [ + new ContpaqiParser(), + // new AspelParser(), + // new SapParser(), + new GenericoParser(), // Fallback + ]; + + public function detectar(string $filePath): array + { + foreach ($this->parsers as $parser) { + if ($parser->puedeManej($filePath)) { + return [ + 'sistema' => $parser->getSistema(), + 'parser' => $parser, + ]; + } + } + throw new Exception('Formato no reconocido'); + } +} +``` + +--- + +## ContpaqiParser + +Parser para balanzas de CONTPAQi. + +### Detección +Busca patrones característicos en el PDF: +- Texto "CONTPAQ" o "CONTPAQi" +- Patrón de códigos: `\d{3}-\d{3}-\d{3}` +- Encabezados típicos: "Saldo Inicial", "Debe", "Haber" + +### Formato de código CONTPAQi +``` +XXX-XXX-XXX + │ │ │ + │ │ └── Detalle (000 = padre) + │ └────── Subcuenta (000 = padre) + └────────── Mayor +``` + +Ejemplos: +- `001-100-000` → Nivel 1 (Mayor: Activo Circulante) +- `101-000-000` → Nivel 2 (Subcuenta: Caja) +- `101-001-000` → Nivel 3 (Detalle) + +### Reglas de mapeo CONTPAQi + +| Cuenta Padre | Rango Hijos | Categoría | +|--------------|-------------|-----------| +| 001-100-000 | 101-000-000 a 154-999-999 | Activos Circulantes | +| 001-200-000 | 155-000-000 a 199-999-999 | Activos No Circulantes | +| 002-100-000 | 200-000-000 a 209-999-999 | Pasivo Circulante | +| 002-200-000 | 210-000-000 a 220-999-999 | Pasivo No Circulante | +| 30X-XXX-XXX | - | Capital Social | +| 310-XXX-XXX | - | Pérdidas Anteriores | +| 311-XXX-XXX | - | Utilidades Anteriores | +| 40X-XXX-XXX | - | Ingresos | +| 5XX-XXX-XXX | - | Gastos Operativos | +| 6XX-XXX-XXX | - | Otros Gastos | +| 7XX-XXX-XXX | - | Gastos Financieros | + +--- + +## GenericoParser + +Parser para archivos Excel/CSV genéricos. + +### Formatos soportados +- `.xlsx` - Excel 2007+ +- `.xls` - Excel 97-2003 +- `.csv` - Valores separados por coma/punto y coma + +### Detección de columnas +Busca headers similares a: + +| Campo | Aliases aceptados | +|-------|-------------------| +| codigo | codigo, cuenta, code, account, cta, numero | +| nombre | nombre, descripcion, name, description, concepto | +| saldo_inicial_deudor | saldo_inicial_deudor, inicial_debe, opening_debit | +| cargos | cargos, debe, debit, debits, movs_deudor | +| abonos | abonos, haber, credit, credits, movs_acreedor | +| saldo_final_deudor | saldo_final_deudor, final_debe, closing_debit | + +### Detección de delimitador CSV +Detecta automáticamente: `,`, `;`, `\t`, `|` + +--- + +## Agregar Nuevo Parser + +1. **Crear clase** en `app/Services/Parsers/`: + +```php +parsers = [ + new ContpaqiParser(), + new AspelParser(), // ← Agregar antes de GenericoParser + new GenericoParser(), +]; +``` + +3. **Agregar reglas de mapeo** en seeder: + +```php +// Crear nuevo seeder: ReglasMapeeoAspelSeeder.php +ReglaMapeo::create([ + 'sistema_origen' => 'aspel', + 'patron_regex' => '/^1\d{3}/', + 'reporte_contable_id' => $balance->id, + 'categoria_contable_id' => $activosCirc->id, + 'prioridad' => 10, +]); +``` + +--- + +## ClasificadorCuentas + +Aplica reglas de mapeo a las cuentas importadas. + +### Proceso: +1. Buscar mapeo específico del cliente +2. Si no existe, buscar regla del sistema +3. Si no existe regla, marcar para revisión + +### Detección de anomalías: +- Cuentas 45X (normalmente pasivo) usadas como gasto +- Cuentas 8XX-9XX (cuentas de orden) +- Códigos sin regla de mapeo + +```php +// Ejemplo de nota de revisión automática +"Código 45X normalmente es pasivo pero podría ser gasto. Verificar clasificación." +``` diff --git a/docs/08-metricas.md b/docs/08-metricas.md new file mode 100644 index 0000000..4810ef6 --- /dev/null +++ b/docs/08-metricas.md @@ -0,0 +1,232 @@ +# 8. Métricas Financieras + +## Resumen + +El sistema calcula **27+ métricas financieras** organizadas en 6 categorías: + +| Categoría | # Métricas | +|-----------|------------| +| Márgenes | 7 | +| Retorno | 4 | +| Eficiencia | 6 | +| Liquidez | 3 | +| Solvencia | 3 | +| Gestión | 4 | + +--- + +## Estados Financieros Base + +### Balance General + +``` +ACTIVOS +├── Activos Circulantes +│ ├── Efectivo +│ ├── Cuentas por Cobrar +│ └── Inventarios +├── Activos No Circulantes +│ ├── Propiedad, Planta y Equipo +│ └── Activos Intangibles +└── TOTAL ACTIVOS + +PASIVOS +├── Pasivo Circulante +│ ├── Cuentas por Pagar +│ └── Deuda Corto Plazo +├── Pasivo No Circulante +│ └── Deuda Largo Plazo +└── TOTAL PASIVOS + +CAPITAL +├── Capital Social +├── Utilidades Retenidas +└── TOTAL CAPITAL + +TOTAL PASIVOS + CAPITAL = TOTAL ACTIVOS +``` + +### Estado de Resultados + +``` +Ingresos +(-) Costo de Venta += Utilidad Bruta +(-) Gastos Operativos += Utilidad Operativa (EBIT) +(-) Otros Gastos +(-) Gastos Financieros += Utilidad Antes de Impuestos (EBT) +(-) Impuestos += UTILIDAD NETA +``` + +### Flujo de Efectivo (Método Indirecto) + +``` +FLUJO DE OPERACIÓN + Utilidad Neta + + Depreciación + - Cambios en Capital de Trabajo + +FLUJO DE INVERSIÓN + - Adquisición de Activos Fijos + + Venta de Activos + +FLUJO DE FINANCIAMIENTO + + Nueva Deuda + - Pago de Deuda + + Aportaciones de Capital + - Dividendos + += FLUJO NETO +``` + +--- + +## Métricas por Categoría + +### 1. Márgenes + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **Margen Bruto** | Utilidad Bruta / Ingresos | Rentabilidad después de costos directos | +| **Margen EBITDA** | EBITDA / Ingresos | Rentabilidad operativa antes de D&A | +| **Margen Operativo** | EBIT / Ingresos | Rentabilidad operativa | +| **Margen Neto** | Utilidad Neta / Ingresos | Rentabilidad final | +| **Margen NOPAT** | NOPAT / Ingresos | Utilidad operativa después de impuestos | +| **Margen OCF** | Flujo Operación / Ingresos | Generación de efectivo operativo | +| **Margen FCF** | Flujo Libre / Ingresos | Efectivo disponible | + +Donde: +- EBITDA = EBIT + Depreciación + Amortización +- NOPAT = EBIT × (1 - Tasa Impuestos) +- FCF = Flujo Operación - CapEx + +### 2. Retorno + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **ROIC** | NOPAT / Capital Invertido | Retorno sobre capital invertido | +| **ROE** | Utilidad Neta / Capital | Retorno para accionistas | +| **ROA** | Utilidad Neta / Activo Total | Eficiencia de activos | +| **ROCE** | EBIT / Capital Empleado | Retorno sobre capital empleado | + +Donde: +- Capital Invertido = Activo Total - Pasivo Circulante +- Capital Empleado = Activo Total - Pasivo Circulante + +### 3. Eficiencia + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **Asset Turnover** | Ingresos / Activo Total | Rotación de activos | +| **Inventory Turnover** | Costo Venta / Inventario | Rotación de inventario | +| **Días Clientes** | (Clientes / Ingresos) × 365 | Días para cobrar | +| **Días Proveedores** | (Proveedores / Costo) × 365 | Días para pagar | +| **Días Inventario** | (Inventario / Costo) × 365 | Días de inventario | +| **Ciclo Conversión** | Días Clientes + Días Inv. - Días Prov. | Ciclo de efectivo | + +### 4. Liquidez + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **Current Ratio** | Activo Circ. / Pasivo Circ. | Capacidad de pago corto plazo | +| **Quick Ratio** | (Activo Circ. - Inv.) / Pasivo Circ. | Liquidez sin inventario | +| **Cash Ratio** | Efectivo / Pasivo Circ. | Liquidez inmediata | + +### 5. Solvencia + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **Net Debt/EBITDA** | (Deuda - Efectivo) / EBITDA | Años para pagar deuda | +| **Interest Coverage** | EBITDA / Gastos Financieros | Cobertura de intereses | +| **Debt Ratio** | Deuda Total / Activo Total | Apalancamiento | + +### 6. Gestión + +| Métrica | Fórmula | Interpretación | +|---------|---------|----------------| +| **Revenue Growth** | (Ingreso Actual - Anterior) / Anterior | Crecimiento de ventas | +| **Key Score** | Crec. Ingreso 12m + Margen EBITDA 12m | Score compuesto | +| **CapEx/Revenue** | CAPEX / Ingresos | Inversión en activos | +| **Effective Tax Rate** | Impuestos / EBT | Tasa impositiva efectiva | + +--- + +## Sistema de Semáforos + +Cada métrica se evalúa contra umbrales para determinar su "tendencia": + +| Tendencia | Color | Significado | +|-----------|-------|-------------| +| muy_positivo | Verde oscuro | Excelente desempeño | +| positivo | Verde claro | Buen desempeño | +| neutral | Amarillo | Desempeño aceptable | +| negativo | Naranja | Área de atención | +| muy_negativo | Rojo | Área crítica | + +### Ejemplo de umbrales (Margen Neto): + +| Tendencia | Umbral | +|-----------|--------| +| muy_positivo | ≥ 20% | +| positivo | ≥ 10% | +| neutral | ≥ 5% | +| negativo | ≥ 2% | +| muy_negativo | < 2% | + +### Umbrales por Giro + +Los umbrales pueden personalizarse por industria. Por ejemplo, hotelería puede tener diferentes expectativas de margen que manufactura. + +--- + +## Comparativos + +Para cada métrica se calculan: + +| Comparativo | Descripción | +|-------------|-------------| +| Valor Actual | Valor del periodo actual | +| Periodo Anterior | Valor del periodo inmediato anterior | +| Variación Absoluta | Diferencia numérica | +| Variación % | Cambio porcentual | +| Promedio 3 Periodos | Media de últimos 3 periodos | +| Mismo Periodo Año Anterior | Para comparación interanual | + +--- + +## Implementación + +```php +// CalculadorMetricas.php + +public function calcular(Reporte $reporte): array +{ + // 1. Calcular estados financieros por periodo + foreach ($balanzas as $balanza) { + $periodos[] = [ + 'periodo' => $balanza->periodo_fin, + 'balance_general' => $this->calcularBalanceGeneral($balanza), + 'estado_resultados' => $this->calcularEstadoResultados($balanza), + ]; + } + + // 2. Calcular métricas del último periodo + $metricas = $this->calcularTodasLasMetricas($ultimoPeriodo); + + // 3. Calcular comparativos + $comparativos = $this->calcularComparativos($periodos, $metricas); + + // 4. Calcular flujo de efectivo + $flujoEfectivo = $this->calcularFlujoEfectivo($periodos); + + return [ + 'periodos' => $periodos, + 'metricas' => $metricas, + 'comparativos' => $comparativos, + 'flujo_efectivo' => $flujoEfectivo, + ]; +} +``` diff --git a/docs/09-pdf.md b/docs/09-pdf.md new file mode 100644 index 0000000..c90d48a --- /dev/null +++ b/docs/09-pdf.md @@ -0,0 +1,479 @@ +# 9. Generación de PDF + +## Descripción + +El sistema genera reportes PDF profesionales de **32 páginas** usando Browsershot (wrapper de Puppeteer para Laravel). + +--- + +## Arquitectura + +``` +┌─────────────────┐ +│ ReporteController│ +│ /pdf │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ GeneradorPdf │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Browsershot │ ← Puppeteer headless Chrome +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ React PdfView │ ← Componente optimizado para impresión +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ PDF 32 págs │ +└─────────────────┘ +``` + +--- + +## Estructura del PDF (32 páginas) + +| # | Sección | Contenido | +|---|---------|-----------| +| 1 | Portada | Logo, nombre empresa, periodo | +| 2 | Índice | Tabla de contenidos | +| 3-4 | Resumen Ejecutivo | KPIs principales, semáforos | +| 5-8 | Balance General | Activos, Pasivos, Capital | +| 9-12 | Estado de Resultados | Ingresos, Costos, Gastos | +| 13-14 | Flujo de Efectivo | Operación, Inversión, Financiamiento | +| 15-18 | Análisis de Márgenes | 7 métricas con gráficas | +| 19-20 | Análisis de Retorno | ROIC, ROE, ROA, ROCE | +| 21-22 | Análisis de Eficiencia | Rotaciones, ciclo conversión | +| 23-24 | Análisis de Liquidez | Ratios de liquidez | +| 25-26 | Análisis de Solvencia | Deuda, cobertura | +| 27-28 | Análisis de Gestión | Crecimiento, inversión | +| 29-30 | Comparativos | Tendencias históricas | +| 31 | Notas | Observaciones y anomalías | +| 32 | Glosario | Definiciones de métricas | + +--- + +## Implementación Backend + +### GeneradorPdf.php + +```php +id; + + // Nombre del archivo + $filename = sprintf( + 'reportes/%s/%s-%s.pdf', + $reporte->cliente_id, + $reporte->periodo_fin->format('Y-m'), + now()->timestamp + ); + + $fullPath = storage_path('app/public/' . $filename); + + // Asegurar directorio existe + $dir = dirname($fullPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + // Generar PDF con Browsershot + Browsershot::url($url) + ->setNodeBinary(config('browsershot.node_binary')) + ->setNpmBinary(config('browsershot.npm_binary')) + ->setChromePath(config('browsershot.chrome_path')) + ->format('Letter') + ->margins(10, 10, 10, 10) + ->showBackground() + ->waitUntilNetworkIdle() + ->timeout(120) + ->save($fullPath); + + // Actualizar reporte + $reporte->update([ + 'pdf_path' => $filename, + 'status' => 'completado', + ]); + + return $filename; + } +} +``` + +### Configuración Browsershot + +```php +// config/browsershot.php +return [ + 'node_binary' => env('NODE_BINARY', '/usr/bin/node'), + 'npm_binary' => env('NPM_BINARY', '/usr/bin/npm'), + 'chrome_path' => env('CHROME_PATH', null), // null = usa Chromium de Puppeteer +]; +``` + +--- + +## Implementación Frontend + +### PdfView Component + +```tsx +// src/pages/PdfView/index.tsx + +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { api } from '@/services/api'; +import { Reporte } from '@/types'; + +// Importar secciones +import Portada from './sections/Portada'; +import Indice from './sections/Indice'; +import ResumenEjecutivo from './sections/ResumenEjecutivo'; +import BalanceGeneral from './sections/BalanceGeneral'; +import EstadoResultados from './sections/EstadoResultados'; +import FlujoEfectivo from './sections/FlujoEfectivo'; +import AnalisisMargenes from './sections/AnalisisMargenes'; +import AnalisisRetorno from './sections/AnalisisRetorno'; +import AnalisisEficiencia from './sections/AnalisisEficiencia'; +import AnalisisLiquidez from './sections/AnalisisLiquidez'; +import AnalisisSolvencia from './sections/AnalisisSolvencia'; +import AnalisisGestion from './sections/AnalisisGestion'; +import Comparativos from './sections/Comparativos'; +import Notas from './sections/Notas'; +import Glosario from './sections/Glosario'; + +export default function PdfView() { + const { id } = useParams<{ id: string }>(); + const [reporte, setReporte] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchReporte = async () => { + try { + const data = await api.reportes.get(Number(id)); + setReporte(data); + } finally { + setLoading(false); + } + }; + fetchReporte(); + }, [id]); + + if (loading || !reporte) { + return
Cargando...
; + } + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} +``` + +### Estilos de Impresión + +```css +/* src/styles/pdf.css */ + +@media print { + /* Ocultar elementos no imprimibles */ + .no-print { + display: none !important; + } + + /* Saltos de página */ + .page-break { + page-break-after: always; + } + + .page-break-before { + page-break-before: always; + } + + .avoid-break { + page-break-inside: avoid; + } +} + +/* Contenedor PDF */ +.pdf-container { + width: 8.5in; + margin: 0 auto; + background: white; + font-family: 'Inter', sans-serif; +} + +/* Página individual */ +.pdf-page { + width: 8.5in; + min-height: 11in; + padding: 0.5in; + box-sizing: border-box; + position: relative; +} + +/* Header de página */ +.pdf-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.25in; + border-bottom: 2px solid #1A1F36; + margin-bottom: 0.25in; +} + +/* Footer de página */ +.pdf-footer { + position: absolute; + bottom: 0.25in; + left: 0.5in; + right: 0.5in; + display: flex; + justify-content: space-between; + font-size: 10px; + color: #666; +} + +/* Tabla de datos */ +.pdf-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.pdf-table th, +.pdf-table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid #e5e7eb; +} + +.pdf-table th { + background: #f3f4f6; + font-weight: 600; +} + +/* Semáforos */ +.indicator { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; +} + +.indicator-muy_positivo { background: #059669; } +.indicator-positivo { background: #10b981; } +.indicator-neutral { background: #f59e0b; } +.indicator-negativo { background: #f97316; } +.indicator-muy_negativo { background: #ef4444; } +``` + +--- + +## Componentes de Sección + +### Portada + +```tsx +// src/pages/PdfView/sections/Portada.tsx + +interface PortadaProps { + reporte: Reporte; +} + +export default function Portada({ reporte }: PortadaProps) { + return ( +
+ {/* Logo */} + Logo + + {/* Nombre empresa */} +

+ {reporte.cliente.nombre_empresa} +

+ + {/* Tipo de reporte */} +

+ Reporte Financiero +

+ + {/* Periodo */} +

+ {formatPeriodo(reporte.periodo_tipo, reporte.periodo_inicio, reporte.periodo_fin)} +

+ + {/* Fecha generación */} +

+ Generado: {formatDate(reporte.fecha_generacion)} +

+ + {/* Branding */} +
+

+ Powered by Horux 360 +

+
+
+ ); +} +``` + +### Resumen Ejecutivo + +```tsx +// src/pages/PdfView/sections/ResumenEjecutivo.tsx + +export default function ResumenEjecutivo({ reporte }: { reporte: Reporte }) { + const { metricas, comparativos } = reporte.data_calculada; + + const kpis = [ + { nombre: 'Ingresos', valor: metricas.ingresos, formato: 'currency' }, + { nombre: 'Margen EBITDA', valor: metricas.margen_ebitda, formato: 'percent' }, + { nombre: 'Margen Neto', valor: metricas.margen_neto, formato: 'percent' }, + { nombre: 'ROIC', valor: metricas.roic, formato: 'percent' }, + { nombre: 'Current Ratio', valor: metricas.current_ratio, formato: 'number' }, + { nombre: 'Net Debt/EBITDA', valor: metricas.net_debt_ebitda, formato: 'number' }, + ]; + + return ( + <> +
+ + +
+ {kpis.map((kpi) => ( + + ))} +
+ +

Semáforos de Rendimiento

+ + + +
+ +
+ + +

Tendencias Principales

+ + + +
+ + ); +} +``` + +--- + +## Requisitos del Servidor + +### Instalación de Dependencias + +```bash +# Node.js 18+ +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Puppeteer dependencies (Ubuntu/Debian) +sudo apt-get install -y \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 + +# Instalar Puppeteer +cd backend +npm install puppeteer +``` + +### Configuración en Producción + +```env +# .env +NODE_BINARY=/usr/bin/node +NPM_BINARY=/usr/bin/npm +CHROME_PATH=/usr/bin/google-chrome-stable +FRONTEND_URL=https://app.horux360.com +``` + +--- + +## Troubleshooting + +### Error: "Unable to launch browser" + +```bash +# Verificar Chrome instalado +which google-chrome-stable + +# Instalar Chrome si no existe +wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb +sudo dpkg -i google-chrome-stable_current_amd64.deb +sudo apt-get -f install +``` + +### Error: "Timeout exceeded" + +Aumentar timeout en `GeneradorPdf.php`: + +```php +Browsershot::url($url) + ->timeout(300) // 5 minutos + ->waitUntilNetworkIdle(false) // No esperar red + ->save($fullPath); +``` + +### Error: "Missing fonts" + +```bash +# Instalar fuentes +sudo apt-get install -y fonts-liberation fonts-noto-color-emoji +``` + diff --git a/docs/10-administracion.md b/docs/10-administracion.md new file mode 100644 index 0000000..ec86ab9 --- /dev/null +++ b/docs/10-administracion.md @@ -0,0 +1,392 @@ +# 10. Panel de Administración + +## Descripción + +El panel de administración permite a usuarios con rol **admin** gestionar la configuración global del sistema. + +--- + +## Acceso + +- **URL**: `/admin` +- **Rol requerido**: `admin` +- **Middleware**: `auth:sanctum`, `role:admin` + +--- + +## Secciones + +### 1. Gestión de Usuarios + +**Ruta**: `/admin/usuarios` + +Permite crear, editar y eliminar usuarios del sistema. + +| Campo | Descripción | +|-------|-------------| +| nombre | Nombre completo | +| email | Email único | +| password | Contraseña (mínimo 8 caracteres) | +| role | admin, analista, cliente, empleado | +| cliente_id | Cliente asignado (requerido para cliente/empleado) | +| activo | Estado del usuario | + +#### API Endpoints + +``` +GET /api/admin/usuarios - Listar usuarios +POST /api/admin/usuarios - Crear usuario +GET /api/admin/usuarios/{id} - Ver usuario +PUT /api/admin/usuarios/{id} - Actualizar usuario +DELETE /api/admin/usuarios/{id} - Eliminar usuario +``` + +#### Ejemplo de creación + +```bash +curl -X POST /api/admin/usuarios \ + -H "Authorization: Bearer {token}" \ + -H "Content-Type: application/json" \ + -d '{ + "nombre": "Juan Pérez", + "email": "juan@empresa.com", + "password": "password123", + "role": "analista" + }' +``` + +--- + +### 2. Gestión de Giros + +**Ruta**: `/admin/giros` + +Catálogo de giros de negocio (industrias). + +| Campo | Descripción | +|-------|-------------| +| nombre | Nombre del giro | +| activo | Estado | + +#### Giros predeterminados + +1. Agricultura +2. Alimentación y Bebidas +3. Automotriz +4. Comercio Mayorista +5. Comercio Minorista +6. Construcción +7. Consultoría +8. Educación +9. Energía +10. Farmacéutico +11. Financiero +12. Hotelería +13. Inmobiliario +14. Logística +15. Manufactura +16. Minería +17. Publicidad +18. Salud +19. Seguros +20. Software +21. Telecomunicaciones +22. Textil +23. Transporte +24. Turismo + +#### API Endpoints + +``` +GET /api/admin/giros - Listar giros +POST /api/admin/giros - Crear giro +PUT /api/admin/giros/{id} - Actualizar giro +DELETE /api/admin/giros/{id} - Eliminar giro +``` + +--- + +### 3. Gestión de Umbrales + +**Ruta**: `/admin/umbrales` + +Configuración de umbrales para el sistema de semáforos. + +| Campo | Descripción | +|-------|-------------| +| metrica | Nombre de la métrica | +| muy_positivo | Umbral verde oscuro | +| positivo | Umbral verde claro | +| neutral | Umbral amarillo | +| negativo | Umbral naranja | +| muy_negativo | Umbral rojo | +| giro_id | Giro específico (null = general) | + +#### Umbrales por defecto + +| Métrica | Muy Positivo | Positivo | Neutral | Negativo | +|---------|--------------|----------|---------|----------| +| margen_bruto | ≥40% | ≥30% | ≥20% | ≥10% | +| margen_ebitda | ≥25% | ≥15% | ≥10% | ≥5% | +| margen_operativo | ≥20% | ≥12% | ≥8% | ≥4% | +| margen_neto | ≥15% | ≥10% | ≥5% | ≥2% | +| roic | ≥20% | ≥15% | ≥10% | ≥5% | +| roe | ≥20% | ≥15% | ≥10% | ≥5% | +| roa | ≥12% | ≥8% | ≥5% | ≥2% | +| current_ratio | ≥2.0 | ≥1.5 | ≥1.2 | ≥1.0 | +| quick_ratio | ≥1.5 | ≥1.0 | ≥0.8 | ≥0.5 | +| net_debt_ebitda | ≤1.0 | ≤2.0 | ≤3.0 | ≤4.0 | +| interest_coverage | ≥8.0 | ≥5.0 | ≥3.0 | ≥1.5 | + +#### API Endpoints + +``` +GET /api/admin/umbrales - Listar umbrales +POST /api/admin/umbrales - Crear umbral +PUT /api/admin/umbrales/{id} - Actualizar umbral +DELETE /api/admin/umbrales/{id} - Eliminar umbral +``` + +#### Ejemplo: Umbral específico por giro + +```bash +# Crear umbral de margen bruto específico para Hotelería +curl -X POST /api/admin/umbrales \ + -H "Authorization: Bearer {token}" \ + -d '{ + "metrica": "margen_bruto", + "muy_positivo": 0.50, + "positivo": 0.40, + "neutral": 0.30, + "negativo": 0.20, + "giro_id": 12 + }' +``` + +--- + +### 4. Reglas de Mapeo + +**Ruta**: `/admin/reglas-mapeo` + +Reglas para clasificar cuentas automáticamente según el sistema contable. + +| Campo | Descripción | +|-------|-------------| +| sistema_origen | contpaqi, aspel, sap, etc. | +| cuenta_padre_codigo | Código de cuenta padre | +| rango_inicio | Inicio del rango de códigos | +| rango_fin | Fin del rango | +| patron_regex | Patrón regex alternativo | +| reporte_contable_id | Balance General o Estado de Resultados | +| categoria_contable_id | Categoría destino | +| prioridad | Mayor número = mayor prioridad | +| activo | Estado de la regla | + +#### Reglas CONTPAQi predeterminadas + +| Cuenta Padre | Rango | Categoría | +|--------------|-------|-----------| +| 001-100-000 | 101-000-000 a 154-999-999 | Activos Circulantes | +| 001-200-000 | 155-000-000 a 199-999-999 | Activos No Circulantes | +| 002-100-000 | 200-000-000 a 209-999-999 | Pasivo Circulante | +| 002-200-000 | 210-000-000 a 220-999-999 | Pasivo No Circulante | +| - | 30X-XXX-XXX | Capital Social | +| - | 310-XXX-XXX | Pérdidas Anteriores | +| - | 311-XXX-XXX | Utilidades Anteriores | +| - | 40X-XXX-XXX | Ingresos | +| - | 5XX-XXX-XXX | Gastos Operativos | +| - | 6XX-XXX-XXX | Otros Gastos | +| - | 7XX-XXX-XXX | Gastos Financieros | + +#### API Endpoints + +``` +GET /api/admin/reglas-mapeo - Listar reglas +POST /api/admin/reglas-mapeo - Crear regla +PUT /api/admin/reglas-mapeo/{id} - Actualizar regla +DELETE /api/admin/reglas-mapeo/{id} - Eliminar regla +``` + +#### Ejemplo: Agregar regla para Aspel + +```bash +curl -X POST /api/admin/reglas-mapeo \ + -H "Authorization: Bearer {token}" \ + -d '{ + "sistema_origen": "aspel", + "patron_regex": "^1[0-4]\\d{2}", + "reporte_contable_id": 1, + "categoria_contable_id": 1, + "prioridad": 10, + "activo": true + }' +``` + +--- + +## Interfaz de Usuario + +### Layout Admin + +```tsx +// src/pages/Admin/Layout.tsx + +export default function AdminLayout({ children }) { + const menuItems = [ + { path: '/admin/usuarios', label: 'Usuarios', icon: UsersIcon }, + { path: '/admin/giros', label: 'Giros', icon: BuildingIcon }, + { path: '/admin/umbrales', label: 'Umbrales', icon: ChartIcon }, + { path: '/admin/reglas-mapeo', label: 'Reglas Mapeo', icon: MapIcon }, + ]; + + return ( +
+ +
{children}
+
+ ); +} +``` + +### Tabla CRUD genérica + +```tsx +// src/components/admin/CrudTable.tsx + +interface CrudTableProps { + data: T[]; + columns: Column[]; + onEdit: (item: T) => void; + onDelete: (item: T) => void; + onCreate: () => void; + title: string; +} + +export default function CrudTable({ + data, + columns, + onEdit, + onDelete, + onCreate, + title, +}: CrudTableProps) { + return ( +
+
+

{title}

+ +
+ + + + + {columns.map((col) => ( + + ))} + + + + + {data.map((item) => ( + + {columns.map((col) => ( + + ))} + + + ))} + +
{col.label}Acciones
+ {col.render ? col.render(item) : item[col.key]} + + + +
+
+ ); +} +``` + +--- + +## Auditoría + +Todas las acciones administrativas se registran en el log: + +```php +// Ejemplo en AdminController +Log::channel('admin')->info('Usuario creado', [ + 'admin_id' => auth()->id(), + 'user_id' => $user->id, + 'email' => $user->email, + 'ip' => request()->ip(), +]); +``` + +### Configuración de logging + +```php +// config/logging.php +'channels' => [ + 'admin' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/admin.log'), + 'level' => 'info', + 'days' => 90, + ], +], +``` + +--- + +## Permisos por Rol + +| Acción | Admin | Analista | Cliente | Empleado | +|--------|-------|----------|---------|----------| +| Gestionar usuarios | ✓ | ✗ | ✗ | ✗ | +| Gestionar giros | ✓ | ✗ | ✗ | ✗ | +| Gestionar umbrales | ✓ | ✗ | ✗ | ✗ | +| Gestionar reglas mapeo | ✓ | ✗ | ✗ | ✗ | +| Ver todos los clientes | ✓ | ✓ | ✗ | ✗ | +| Crear clientes | ✓ | ✓ | ✗ | ✗ | +| Subir balanzas | ✓ | ✓ | ✗ | ✗ | +| Generar reportes | ✓ | ✓ | ✗ | ✗ | +| Ver dashboard propio | ✓ | ✓ | ✓ | * | +| Descargar PDF | ✓ | ✓ | ✓ | * | + +\* Según permisos configurados + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8edb5df --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Documentación - Horux Strategy Platform + +## Índice + +1. [Descripción General](./01-descripcion-general.md) +2. [Arquitectura del Sistema](./02-arquitectura.md) +3. [Instalación y Configuración](./03-instalacion.md) +4. [API Reference](./04-api-reference.md) +5. [Guía del Frontend](./05-frontend.md) +6. [Base de Datos](./06-base-de-datos.md) +7. [Parsers de Balanzas](./07-parsers.md) +8. [Métricas Financieras](./08-metricas.md) +9. [Generación de PDF](./09-pdf.md) +10. [Administración](./10-administracion.md) + +## Inicio Rápido + +```bash +# Backend +cd backend +composer install +cp .env.example .env +php artisan key:generate +php artisan migrate --seed +php artisan serve + +# Frontend (otra terminal) +cd frontend +npm install +npm run dev +``` + +Accede a http://localhost:5173 con: +- Email: admin@horux360.com +- Password: password diff --git a/docs/plans/2025-01-31-horux-strategy-design.md b/docs/plans/2025-01-31-horux-strategy-design.md new file mode 100644 index 0000000..0771888 --- /dev/null +++ b/docs/plans/2025-01-31-horux-strategy-design.md @@ -0,0 +1,384 @@ +# Horux Strategy Platform - Diseño de Sistema + +**Fecha:** 2025-01-31 +**Proyecto:** Plataforma de Reportes Financieros +**Cliente:** Horux 360 + +--- + +## 1. Resumen Ejecutivo + +Plataforma web para generar reportes financieros automatizados a partir de balanzas de comprobación. Permite a empresas subir sus datos contables de múltiples sistemas (CONTPAQi, Aspel, SAP, etc.) y obtener dashboards interactivos con métricas financieras y exportación a PDF profesional. + +--- + +## 2. Stack Tecnológico + +| Componente | Tecnología | +|------------|------------| +| Backend | Laravel 11 (API REST) | +| Autenticación | Laravel Sanctum | +| Frontend | React 18 + TypeScript | +| Base de datos | MySQL / PostgreSQL | +| Gráficas | Recharts | +| Generación PDF | Puppeteer / Browsershot | +| Parsing PDFs | spatie/pdf-to-text + regex | + +--- + +## 3. Roles de Usuario + +| Rol | Permisos | +|-----|----------| +| **Administrador** | Acceso total, configuración global, gestión de umbrales | +| **Analista** | Gestionar clientes asignados, procesar balanzas, generar reportes | +| **Cliente** | Ver dashboard propio, descargar PDFs, gestionar empleados | +| **Empleado** | Permisos configurables por el cliente | + +--- + +## 4. Sistemas Contables Soportados + +- CONTPAQi (inicial, con ejemplos) +- Aspel +- Alegra +- SAP +- Odoo +- Parser genérico (CSV/Excel configurable) + +### 4.1 Arquitectura de Parsers + +``` +Upload archivo → Detector de formato → Parser específico → Formato normalizado +``` + +Cada parser normaliza los datos a una estructura estándar interna, permitiendo agregar nuevos sistemas sin modificar el resto del código. + +--- + +## 5. Modelo de Datos + +### 5.1 Usuarios y Acceso + +```sql +-- users +id, nombre, email, password, role, cliente_id (nullable) + +-- clientes +id, nombre_empresa, logo, giro_id, moneda, configuracion (JSON) + +-- permisos_empleado +user_id, cliente_id, permisos (JSON) +``` + +### 5.2 Catálogos + +```sql +-- giros +id, nombre, activo + +-- umbrales +id, metrica, muy_positivo, positivo, neutral, negativo, muy_negativo, giro_id + +-- reportes_contables +id, nombre ("Balance General", "Estado de Resultados") + +-- categorias_contables +id, reporte_contable_id, nombre, orden +``` + +### 5.3 Categorías Contables + +**Balance General:** +| ID | Categoría | +|----|-----------| +| 1 | Activos Circulantes | +| 2 | Activos No Circulantes | +| 3 | Pasivo Circulante | +| 4 | Pasivo No Circulante | +| 5 | Capital Social | +| 6 | Pérdidas Ejercicios Anteriores | +| 7 | Utilidades Ejercicios Anteriores | + +**Estado de Resultados:** +| ID | Categoría | +|----|-----------| +| 8 | Ingresos | +| 9 | Costo de Venta | +| 10 | Gastos Operativos | +| 11 | Otros Gastos | +| 12 | Gastos Financieros | + +### 5.4 Balanzas y Cuentas + +```sql +-- balanzas +id, cliente_id, periodo_inicio, periodo_fin, sistema_origen, archivo_original, status + +-- cuentas +id, 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 (boolean) + +-- reglas_mapeo +id, sistema_origen, cuenta_padre_codigo, +rango_hijo_inicio, rango_hijo_fin, +reporte_contable_id, categoria_contable_id, prioridad + +-- mapeo_cuentas (excepciones por cliente) +cliente_id, codigo_patron, categoria_destino, notas +``` + +### 5.5 Reportes + +```sql +-- reportes +id, cliente_id, nombre, periodo_tipo, fecha_generacion, data_calculada (JSON), pdf_path +``` + +--- + +## 6. Reglas de Mapeo Contable (CONTPAQi) + +| Cuenta Padre | Rango Hijos | Reporte | Categoría | +|--------------|-------------|---------|-----------| +| 001-100-000 | 101-000-000 a 154-999-999 | Balance | Activos Circulantes | +| 001-200-000 | 155-000-000 a 199-999-999 | Balance | Activos No Circulantes | +| 002-100-000 | 200-000-000 a 209-999-999 | Balance | Pasivo Circulante | +| 002-200-000 | 210-000-000 a 220-999-999 | Balance | Pasivo No Circulante | +| 300-000-000 | - | Balance | Capital Social | +| 310-000-000 | - | Balance | Pérdidas Ejercicios Anteriores | +| 311-000-000 | - | Balance | Utilidades Ejercicios Anteriores | +| 40X-000-000 | 401 a 409 | Edo. Resultados | Ingresos | +| 5XX-000-000 | 500 a 599 | Edo. Resultados | Gastos Operativos | +| 6XX-000-000 | 600 a 699 | Edo. Resultados | Otros Gastos | +| 70X-000-000 | 700 a 799 | Edo. Resultados | Gastos Financieros | + +--- + +## 7. Flujo de Usuario + +1. **Crear/seleccionar cliente** - Nombre, logo, giro, moneda +2. **Subir balanzas** - 2+ archivos PDF/Excel/CSV +3. **Detección automática** - Sistema identifica origen (CONTPAQi, etc.) +4. **Clasificación automática** - Mapeo a categorías contables +5. **Revisión de anomalías** - Corregir cuentas mal clasificadas +6. **Limpieza de cuentas** - Desmarcar cuentas a excluir +7. **Procesamiento** - Cálculo de métricas +8. **Dashboard** - Visualización interactiva +9. **Exportar PDF** - Descarga de reporte + +--- + +## 8. Métricas Financieras + +### 8.1 Estados Financieros Calculados + +- Balance General +- Estado de Resultados +- Flujo de Efectivo (método indirecto) + +### 8.2 Métricas por Categoría + +**Márgenes:** +- Margen Bruto = Utilidad Bruta / Ingresos +- Margen EBITDA = EBITDA / Ingresos +- Margen Operativo = EBIT / Ingresos +- Margen Neto = Utilidad Neta / Ingresos +- Margen NOPAT = NOPAT / Ingresos +- Margen OCF = Flujo Operación / Ingresos +- Margen FCF = Flujo Libre / Ingresos + +**Retorno:** +- ROIC = NOPAT / Capital Invertido +- ROE = Utilidad Neta / Capital +- ROA = Utilidad Neta / Activo Total +- ROCE = EBIT / Capital Empleado + +**Eficiencia:** +- Asset Turnover = Ingresos / Activo Total +- Inventory Turnover = Costo Venta / Inventario +- Días Clientes = (Clientes / Ingresos) × 365 +- Días Proveedores = (Proveedores / Costo) × 365 +- Días Inventario = (Inventario / Costo) × 365 +- Ciclo Conversión = Días Clientes + Días Inv. - Días Prov. + +**Liquidez:** +- Current Ratio = Activo Circ. / Pasivo Circ. +- Quick Ratio = (Activo Circ. - Inventario) / Pasivo Circ. +- Cash Ratio = Efectivo / Pasivo Circ. + +**Solvencia:** +- Net Debt / EBITDA = (Deuda Total - Efectivo) / EBITDA +- Interest Coverage = EBITDA / Gastos Financieros +- Debt Ratio = Deuda Total / Activo Total + +**Gestión:** +- Revenue Growth = (Ingreso Actual - Anterior) / Anterior +- Key Score = Crecimiento Ingreso 12m + Margen EBITDA 12m +- CapEx / Revenue = CAPEX / Ingresos +- Effective Tax Rate = Impuestos / EBT + +### 8.3 Comparativos + +Para cada métrica: +- Valor actual +- Promedio 3 periodos +- Periodo anterior +- Mismo periodo año anterior +- Variación absoluta y porcentual +- Tendencia (Muy positiva → Muy negativa) + +--- + +## 9. Dashboard + +### 9.1 Secciones + +1. Mensajes Destacados (KPIs + insights) +2. Resumen Mensual +3. Resumen 12m +4. Márgenes +5. Márgenes Deep Dive +6. Resultados +7. Balance +8. Capital de Trabajo +9. Flujo de Efectivo +10. Métricas +11. Indicadores +12. Negocios (por unidad) +13. Estados Financieros + +### 9.2 Componentes React + +``` +components/ +├── charts/ +│ ├── BarChart.tsx +│ ├── LineChart.tsx +│ ├── StackedBarChart.tsx +│ ├── WaterfallChart.tsx +│ └── ComboChart.tsx +├── cards/ +│ ├── KPICard.tsx +│ ├── InsightCard.tsx +│ └── MetricTable.tsx +├── tables/ +│ ├── FinancialStatement.tsx +│ └── MetricsGrid.tsx +└── layout/ + ├── DashboardNav.tsx + ├── PeriodSelector.tsx + └── ExportButton.tsx +``` + +--- + +## 10. Panel Administrativo + +### 10.1 Módulos + +1. Dashboard admin (estadísticas) +2. Gestión de usuarios +3. Gestión de clientes +4. Catálogo de giros +5. Configuración de umbrales +6. Reglas de mapeo contable +7. Parsers de sistemas +8. Configuración general + +### 10.2 Gestión de Empleados (Cliente) + +El cliente puede: +- Agregar empleados +- Configurar permisos granulares por empleado +- Permisos disponibles: ver dashboard, ver estados financieros, exportar PDF, etc. + +--- + +## 11. Generación de PDF + +### 11.1 Enfoque + +Usar Puppeteer/Browsershot para renderizar el dashboard de React a PDF, manteniendo el mismo diseño pixel-perfect. + +### 11.2 Estructura (32 páginas) + +1. Portada +2. Mensajes Destacados +3. Separador "Resumen" +4. Resumen Mensual +5. Resumen 12m +6. Márgenes +7. Márgenes Deep Dive +8. Separador "Resultados" +9. Resultados +10. Separador "Balance" +11. Balance +12. Capital de Trabajo +13. Separador "Flujo" +14. Flujo de Efectivo +15. Separador "Métricas" +16-17. Métricas +18. Separador "Indicadores" +19. Indicadores +20. Separador "Negocios" +21-22. Negocios por unidad +23. Separador "Estados Financieros" +24-25. Tabla de Métricas +26-27. Estado de Resultados +28-29. Subcategorías Costo/Gasto +30. Balance General +31. Flujo de Efectivo +32. Contraportada + +--- + +## 12. Estructura del Proyecto + +``` +monthly-platform/ +├── backend/ # Laravel +│ ├── app/ +│ │ ├── Http/Controllers/ +│ │ ├── Models/ +│ │ ├── Services/ +│ │ │ ├── Parsers/ +│ │ │ │ ├── ParserInterface.php +│ │ │ │ ├── ContpaqiParser.php +│ │ │ │ ├── AspelParser.php +│ │ │ │ └── GenericoParser.php +│ │ │ ├── DetectorFormato.php +│ │ │ ├── ClasificadorCuentas.php +│ │ │ ├── CalculadorMetricas.php +│ │ │ └── GeneradorPdf.php +│ │ └── ... +│ ├── database/migrations/ +│ └── routes/api.php +│ +└── frontend/ # React + ├── src/ + │ ├── components/ + │ ├── pages/ + │ │ ├── Dashboard/ + │ │ ├── Admin/ + │ │ ├── Cliente/ + │ │ └── PdfView/ + │ ├── hooks/ + │ ├── services/ + │ └── utils/ + └── ... +``` + +--- + +## 13. Consideraciones Futuras + +- Integración directa con APIs de sistemas contables +- Generación de insights con IA +- Comparativas con benchmarks de industria (datos reales) +- App móvil para consulta de reportes +- Alertas automáticas por email cuando métricas salen de rango diff --git a/docs/plans/2025-01-31-implementation-plan.md b/docs/plans/2025-01-31-implementation-plan.md new file mode 100644 index 0000000..229f6f3 --- /dev/null +++ b/docs/plans/2025-01-31-implementation-plan.md @@ -0,0 +1,355 @@ +# Horux Strategy Platform - Plan de Implementación + +**Fecha:** 2025-01-31 +**Documento de diseño:** 2025-01-31-horux-strategy-design.md + +--- + +## Fases de Implementación + +### FASE 1: Fundamentos (Backend) + +#### 1.1 Configuración del proyecto Laravel +- [ ] Crear proyecto Laravel 11 +- [ ] Configurar base de datos (MySQL/PostgreSQL) +- [ ] Instalar y configurar Laravel Sanctum +- [ ] Configurar CORS para React + +#### 1.2 Migraciones de base de datos +- [ ] Tabla `users` (con campo role) +- [ ] Tabla `clientes` +- [ ] Tabla `giros` +- [ ] Tabla `permisos_empleado` +- [ ] Tabla `reportes_contables` +- [ ] Tabla `categorias_contables` +- [ ] Tabla `umbrales` +- [ ] Tabla `balanzas` +- [ ] Tabla `cuentas` +- [ ] Tabla `reglas_mapeo` +- [ ] Tabla `mapeo_cuentas` +- [ ] Tabla `reportes` +- [ ] Seeders para catálogos iniciales (giros, categorías, reglas CONTPAQi) + +#### 1.3 Modelos y relaciones +- [ ] User (con roles) +- [ ] Cliente +- [ ] Giro +- [ ] PermisoEmpleado +- [ ] Umbral +- [ ] Balanza +- [ ] Cuenta +- [ ] ReglaMappeo +- [ ] Reporte + +#### 1.4 Sistema de autenticación +- [ ] Login / Logout +- [ ] Registro (solo admin puede crear usuarios) +- [ ] Middleware por rol +- [ ] Policies para autorización + +--- + +### FASE 2: Parser de Balanzas + +#### 2.1 Arquitectura de parsers +- [ ] Crear `ParserInterface.php` +- [ ] Crear `DetectorFormato.php` +- [ ] Implementar `ContpaqiParser.php` +- [ ] Crear `ParserGenerico.php` (CSV/Excel) + +#### 2.2 Procesamiento de archivos +- [ ] Endpoint para upload de archivos +- [ ] Extracción de texto de PDF +- [ ] Parsing de estructura CONTPAQi +- [ ] Normalización a formato interno +- [ ] Almacenamiento en tabla `cuentas` + +#### 2.3 Clasificación automática +- [ ] Crear `ClasificadorCuentas.php` +- [ ] Aplicar reglas de mapeo +- [ ] Detectar anomalías +- [ ] Marcar cuentas que requieren revisión + +--- + +### FASE 3: Motor de Cálculo + +#### 3.1 Calculador de estados financieros +- [ ] Crear `CalculadorMetricas.php` +- [ ] Calcular Balance General +- [ ] Calcular Estado de Resultados +- [ ] Calcular Flujo de Efectivo (método indirecto) + +#### 3.2 Cálculo de métricas +- [ ] Métricas de márgenes (7 métricas) +- [ ] Métricas de retorno (4 métricas) +- [ ] Métricas de eficiencia (6 métricas) +- [ ] Métricas de liquidez (3 métricas) +- [ ] Métricas de solvencia (3 métricas) +- [ ] Métricas de gestión (4 métricas) + +#### 3.3 Comparativos +- [ ] Calcular promedio 3 periodos +- [ ] Comparar con periodo anterior +- [ ] Comparar con año anterior +- [ ] Calcular variaciones +- [ ] Determinar tendencia según umbrales + +--- + +### FASE 4: API REST + +#### 4.1 Endpoints de autenticación +- [ ] POST /api/login +- [ ] POST /api/logout +- [ ] GET /api/user + +#### 4.2 Endpoints de clientes +- [ ] GET /api/clientes +- [ ] POST /api/clientes +- [ ] GET /api/clientes/{id} +- [ ] PUT /api/clientes/{id} +- [ ] DELETE /api/clientes/{id} + +#### 4.3 Endpoints de balanzas +- [ ] POST /api/clientes/{id}/balanzas (upload) +- [ ] GET /api/clientes/{id}/balanzas +- [ ] GET /api/balanzas/{id}/cuentas +- [ ] PUT /api/balanzas/{id}/cuentas (actualizar exclusiones) +- [ ] PUT /api/cuentas/{id}/clasificacion (corregir anomalía) + +#### 4.4 Endpoints de reportes +- [ ] POST /api/clientes/{id}/reportes (generar) +- [ ] GET /api/clientes/{id}/reportes +- [ ] GET /api/reportes/{id} +- [ ] GET /api/reportes/{id}/pdf + +#### 4.5 Endpoints administrativos +- [ ] CRUD /api/admin/usuarios +- [ ] CRUD /api/admin/giros +- [ ] CRUD /api/admin/umbrales +- [ ] CRUD /api/admin/reglas-mapeo + +--- + +### FASE 5: Frontend - Fundamentos + +#### 5.1 Configuración del proyecto React +- [ ] Crear proyecto con Vite + TypeScript +- [ ] Configurar React Router +- [ ] Configurar Axios para API +- [ ] Configurar Recharts +- [ ] Configurar Tailwind CSS + +#### 5.2 Autenticación +- [ ] Página de Login +- [ ] Context de autenticación +- [ ] Rutas protegidas por rol +- [ ] Interceptor para tokens + +#### 5.3 Layout base +- [ ] Sidebar de navegación +- [ ] Header con usuario +- [ ] Layout para admin +- [ ] Layout para cliente + +--- + +### FASE 6: Frontend - Flujo Principal + +#### 6.1 Gestión de clientes +- [ ] Lista de clientes +- [ ] Formulario crear/editar cliente +- [ ] Subida de logo + +#### 6.2 Subida de balanzas +- [ ] Componente de upload (drag & drop) +- [ ] Preview de archivos +- [ ] Indicador de progreso +- [ ] Detección de sistema origen + +#### 6.3 Revisión y limpieza +- [ ] Vista de anomalías con corrección +- [ ] Lista de cuentas con checkboxes +- [ ] Filtros y búsqueda +- [ ] Guardar exclusiones + +--- + +### FASE 7: Frontend - Dashboard + +#### 7.1 Componentes de gráficas +- [ ] BarChart +- [ ] LineChart +- [ ] StackedBarChart +- [ ] WaterfallChart +- [ ] ComboChart + +#### 7.2 Componentes de cards +- [ ] KPICard (con semáforo) +- [ ] InsightCard +- [ ] MetricTable + +#### 7.3 Secciones del dashboard +- [ ] Mensajes Destacados +- [ ] Resumen Mensual +- [ ] Resumen 12m +- [ ] Márgenes +- [ ] Márgenes Deep Dive +- [ ] Resultados +- [ ] Balance +- [ ] Capital de Trabajo +- [ ] Flujo de Efectivo +- [ ] Métricas +- [ ] Indicadores +- [ ] Negocios +- [ ] Estados Financieros + +--- + +### FASE 8: Generación de PDF + +#### 8.1 Backend +- [ ] Instalar Browsershot +- [ ] Crear `GeneradorPdf.php` +- [ ] Endpoint GET /api/reportes/{id}/pdf + +#### 8.2 Frontend +- [ ] Vista `PdfView` con todas las páginas +- [ ] Estilos específicos para impresión +- [ ] Separadores de sección +- [ ] Portada y contraportada + +--- + +### FASE 9: Panel Administrativo + +#### 9.1 Dashboard admin +- [ ] Estadísticas generales +- [ ] Alertas pendientes + +#### 9.2 Gestión de usuarios +- [ ] CRUD de usuarios +- [ ] Asignación de roles + +#### 9.3 Configuración +- [ ] Editor de umbrales +- [ ] Editor de reglas de mapeo +- [ ] Editor de giros + +--- + +### FASE 10: Funcionalidades de Cliente + +#### 10.1 Gestión de empleados +- [ ] Lista de empleados +- [ ] Crear empleado +- [ ] Configurar permisos + +#### 10.2 Configuración +- [ ] Exclusiones guardadas +- [ ] Preferencias de reporte + +--- + +### FASE 11: Testing y Refinamiento + +#### 11.1 Testing +- [ ] Tests unitarios del motor de cálculo +- [ ] Tests de parsers +- [ ] Tests de API +- [ ] Tests E2E del flujo principal + +#### 11.2 Refinamiento +- [ ] Optimización de rendimiento +- [ ] Manejo de errores +- [ ] Validaciones +- [ ] UX/UI polish + +--- + +## Prioridad de Implementación + +1. **MVP (Fases 1-4, 5-7):** Sistema funcional con CONTPAQi, dashboard básico +2. **PDF (Fase 8):** Exportación de reportes +3. **Admin (Fase 9):** Panel de configuración +4. **Multi-usuario (Fase 10):** Funcionalidades de cliente +5. **Calidad (Fase 11):** Testing y refinamiento + +--- + +## Archivos Clave a Crear + +### Backend (Laravel) + +``` +app/ +├── Http/Controllers/ +│ ├── AuthController.php +│ ├── ClienteController.php +│ ├── BalanzaController.php +│ ├── CuentaController.php +│ ├── ReporteController.php +│ └── Admin/ +│ ├── UsuarioController.php +│ ├── UmbralController.php +│ └── ReglaMappeoController.php +├── Models/ +│ ├── User.php +│ ├── Cliente.php +│ ├── Balanza.php +│ ├── Cuenta.php +│ ├── Reporte.php +│ └── ... +├── Services/ +│ ├── Parsers/ +│ │ ├── ParserInterface.php +│ │ ├── ContpaqiParser.php +│ │ └── DetectorFormato.php +│ ├── ClasificadorCuentas.php +│ ├── CalculadorMetricas.php +│ └── GeneradorPdf.php +└── ... +``` + +### Frontend (React) + +``` +src/ +├── components/ +│ ├── charts/ +│ │ ├── BarChart.tsx +│ │ ├── LineChart.tsx +│ │ ├── StackedBarChart.tsx +│ │ ├── WaterfallChart.tsx +│ │ └── ComboChart.tsx +│ ├── cards/ +│ │ ├── KPICard.tsx +│ │ └── InsightCard.tsx +│ ├── forms/ +│ │ ├── ClienteForm.tsx +│ │ └── UploadBalanza.tsx +│ └── layout/ +│ ├── Sidebar.tsx +│ └── Header.tsx +├── pages/ +│ ├── Login.tsx +│ ├── Dashboard/ +│ │ ├── index.tsx +│ │ ├── Resumen.tsx +│ │ ├── Margenes.tsx +│ │ └── ... +│ ├── Admin/ +│ │ ├── Usuarios.tsx +│ │ └── Umbrales.tsx +│ └── PdfView/ +│ └── index.tsx +├── hooks/ +│ ├── useAuth.ts +│ ├── useCliente.ts +│ └── useReporte.ts +├── services/ +│ └── api.ts +└── types/ + └── index.ts +``` diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a17fd3f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,86 @@ +# Horux Strategy Platform - Frontend + +Frontend con React 18 + TypeScript + Vite para la plataforma de reportes financieros. + +## Requisitos + +- Node.js 18+ +- npm o yarn + +## Instalación + +```bash +# Instalar dependencias +npm install + +# Iniciar servidor de desarrollo +npm run dev + +# Build para producción +npm run build +``` + +## Estructura + +``` +src/ +├── components/ +│ ├── charts/ # Componentes de gráficas (Recharts) +│ │ ├── BarChart.tsx +│ │ └── LineChart.tsx +│ ├── cards/ # Tarjetas KPI y tablas +│ │ ├── KPICard.tsx +│ │ └── MetricTable.tsx +│ ├── forms/ # Formularios +│ │ ├── ClienteForm.tsx +│ │ ├── UploadBalanza.tsx +│ │ └── GenerarReporte.tsx +│ └── layout/ # Layout principal +│ ├── Layout.tsx +│ ├── Sidebar.tsx +│ └── Header.tsx +├── pages/ +│ ├── Login.tsx +│ ├── Clientes/ # Gestión de clientes +│ ├── Dashboard/ # Dashboard de reportes +│ ├── PdfView/ # Vista para generación de PDF +│ └── Admin/ # Panel administrativo +├── context/ +│ └── AuthContext.tsx # Contexto de autenticación +├── services/ +│ └── api.ts # Cliente API +├── types/ +│ └── index.ts # Tipos TypeScript +└── hooks/ # Hooks personalizados +``` + +## Características + +- **Autenticación** con tokens Bearer +- **Dashboard interactivo** con gráficas Recharts +- **Subida de archivos** con drag & drop +- **Panel administrativo** para configuración +- **Generación de PDF** mediante Browsershot +- **Diseño responsivo** con Tailwind CSS + +## Scripts + +```bash +npm run dev # Desarrollo +npm run build # Build producción +npm run preview # Preview del build +npm run lint # Linting +``` + +## Configuración + +El proxy de desarrollo está configurado en `vite.config.ts` para redireccionar las llamadas `/api/*` al backend Laravel en `http://localhost:8000`. + +## Credenciales de prueba + +- **Email**: admin@horux360.com +- **Password**: password + +## Licencia + +Propietario - Horux 360 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ae1b3dd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + Horux Strategy + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b62560e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "horux-strategy-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.0", + "axios": "^1.6.7", + "recharts": "^2.12.0", + "react-dropzone": "^14.2.3", + "clsx": "^2.1.0", + "date-fns": "^3.3.1", + "react-hot-toast": "^2.4.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.18", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b1abb15 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,99 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import Login from './pages/Login'; +import Layout from './components/layout/Layout'; +import ClientesList from './pages/Clientes/ClientesList'; +import ClienteDetail from './pages/Clientes/ClienteDetail'; +import Dashboard from './pages/Dashboard'; +import ReporteView from './pages/ReporteView'; +import PdfView from './pages/PdfView'; +import AdminUsuarios from './pages/Admin/Usuarios'; +import AdminGiros from './pages/Admin/Giros'; +import AdminUmbrales from './pages/Admin/Umbrales'; +import AdminReglasMapeeo from './pages/Admin/ReglasMapeeo'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} + +function AdminRoute({ children }: { children: React.ReactNode }) { + const { isAdmin } = useAuth(); + + if (!isAdmin) { + return ; + } + + return <>{children}; +} + +function AppRoutes() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( + + : } /> + + {/* Vista PDF (sin layout) */} + } /> + + {/* Rutas protegidas con layout */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + + {/* Rutas de administración */} + } /> + } /> + } /> + } /> + } /> + + + } /> + + ); +} + +export default function App() { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/cards/KPICard.tsx b/frontend/src/components/cards/KPICard.tsx new file mode 100644 index 0000000..9c066a0 --- /dev/null +++ b/frontend/src/components/cards/KPICard.tsx @@ -0,0 +1,54 @@ +import { Tendencia, Comparativo } from '../../types'; +import clsx from 'clsx'; + +interface Props { + title: string; + value: string; + subtitle?: string; + trend?: Comparativo; + tendencia?: Tendencia; +} + +export default function KPICard({ title, value, subtitle, trend, tendencia }: Props) { + const getTendenciaColor = (t?: Tendencia) => { + switch (t) { + case 'muy_positivo': return 'bg-emerald-500'; + case 'positivo': return 'bg-emerald-300'; + case 'neutral': return 'bg-amber-400'; + case 'negativo': return 'bg-orange-500'; + case 'muy_negativo': return 'bg-red-500'; + default: return 'bg-gray-300'; + } + }; + + const getTrendIcon = (variacion?: number) => { + if (!variacion) return null; + if (variacion > 0) return '↑'; + if (variacion < 0) return '↓'; + return '→'; + }; + + return ( +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} + {trend && ( +

0 ? 'text-green-600' : 'text-red-600' + )}> + {getTrendIcon(trend.variacion_porcentual)} {Math.abs(trend.variacion_porcentual).toFixed(1)}% + vs anterior +

+ )} +
+ {tendencia && ( +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/cards/MetricTable.tsx b/frontend/src/components/cards/MetricTable.tsx new file mode 100644 index 0000000..372ff4d --- /dev/null +++ b/frontend/src/components/cards/MetricTable.tsx @@ -0,0 +1,88 @@ +import { Metrica, Comparativo, Tendencia } from '../../types'; +import clsx from 'clsx'; + +interface MetricaConComparativo extends Metrica { + comparativo?: Comparativo; +} + +interface Props { + title: string; + metricas: MetricaConComparativo[]; +} + +export default function MetricTable({ title, metricas }: Props) { + const getTendenciaClass = (t?: Tendencia) => { + switch (t) { + case 'muy_positivo': return 'tendencia-muy-positivo'; + case 'positivo': return 'tendencia-positivo'; + case 'neutral': return 'tendencia-neutral'; + case 'negativo': return 'tendencia-negativo'; + case 'muy_negativo': return 'tendencia-muy-negativo'; + default: return 'bg-gray-100'; + } + }; + + const formatValue = (valor: number, nombre: string) => { + // Determinar si es porcentaje o ratio + if (nombre.toLowerCase().includes('ratio') || nombre.toLowerCase().includes('coverage')) { + return valor.toFixed(2); + } + return `${(valor * 100).toFixed(1)}%`; + }; + + return ( +
+

{title}

+
+ + + + + + + + + + + + {metricas.filter(m => m).map((metrica, index) => ( + + + + + + + + ))} + +
MétricaValorvs AnteriorProm. 3PEstado
{metrica.nombre} + {formatValue(metrica.valor, metrica.nombre)} + + {metrica.comparativo ? ( + 0 ? 'text-green-600' : 'text-red-600' + )}> + {metrica.comparativo.variacion_porcentual > 0 ? '+' : ''} + {metrica.comparativo.variacion_porcentual.toFixed(1)}% + + ) : ( + - + )} + + {metrica.comparativo?.promedio_3_periodos != null ? ( + formatValue(metrica.comparativo.promedio_3_periodos, metrica.nombre) + ) : ( + - + )} + + + {metrica.tendencia?.replace('_', ' ') || 'N/A'} + +
+
+
+ ); +} diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..cad9080 --- /dev/null +++ b/frontend/src/components/charts/BarChart.tsx @@ -0,0 +1,61 @@ +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; + +interface DataItem { + name: string; + valor: number; +} + +interface Props { + data: DataItem[]; + horizontal?: boolean; +} + +export default function BarChartComponent({ data, horizontal = false }: Props) { + const getColor = (value: number) => { + return value >= 0 ? '#10b981' : '#ef4444'; + }; + + const formatValue = (value: number) => { + if (Math.abs(value) >= 1000000) { + return `${(value / 1000000).toFixed(1)}M`; + } + if (Math.abs(value) >= 1000) { + return `${(value / 1000).toFixed(1)}K`; + } + return value.toFixed(0); + }; + + if (horizontal) { + return ( + + + + + + formatValue(value)} /> + + {data.map((entry, index) => ( + + ))} + + + + ); + } + + return ( + + + + + + formatValue(value)} /> + + {data.map((entry, index) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/charts/LineChart.tsx b/frontend/src/components/charts/LineChart.tsx new file mode 100644 index 0000000..b99f4d7 --- /dev/null +++ b/frontend/src/components/charts/LineChart.tsx @@ -0,0 +1,33 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; + +interface Props { + data: Record[]; + lines: string[]; +} + +const COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; + +export default function LineChartComponent({ data, lines }: Props) { + return ( + + + + + `${value.toFixed(0)}%`} /> + `${value.toFixed(1)}%`} /> + + {lines.map((line, index) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/forms/ClienteForm.tsx b/frontend/src/components/forms/ClienteForm.tsx new file mode 100644 index 0000000..e9692bc --- /dev/null +++ b/frontend/src/components/forms/ClienteForm.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react'; +import { clientesApi, girosApi } from '../../services/api'; +import { Giro } from '../../types'; +import toast from 'react-hot-toast'; + +interface Props { + onSuccess: () => void; + onCancel: () => void; +} + +export default function ClienteForm({ onSuccess, onCancel }: Props) { + const [giros, setGiros] = useState([]); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + nombre_empresa: '', + giro_id: '', + moneda: 'MXN', + }); + const [logo, setLogo] = useState(null); + + useEffect(() => { + girosApi.list().then(setGiros).catch(() => toast.error('Error al cargar giros')); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const data = new FormData(); + data.append('nombre_empresa', formData.nombre_empresa); + data.append('giro_id', formData.giro_id); + data.append('moneda', formData.moneda); + if (logo) { + data.append('logo', logo); + } + + await clientesApi.create(data); + onSuccess(); + } catch { + toast.error('Error al crear cliente'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setFormData({ ...formData, nombre_empresa: e.target.value })} + className="input" + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setLogo(e.target.files?.[0] || null)} + className="input" + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/forms/GenerarReporte.tsx b/frontend/src/components/forms/GenerarReporte.tsx new file mode 100644 index 0000000..fa5b8c7 --- /dev/null +++ b/frontend/src/components/forms/GenerarReporte.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { reportesApi } from '../../services/api'; +import { Balanza } from '../../types'; +import toast from 'react-hot-toast'; + +interface Props { + clienteId: number; + balanzas: Balanza[]; + onSuccess: () => void; + onCancel: () => void; +} + +export default function GenerarReporte({ clienteId, balanzas, onSuccess, onCancel }: Props) { + const [loading, setLoading] = useState(false); + const [nombre, setNombre] = useState(''); + const [selectedBalanzas, setSelectedBalanzas] = useState([]); + + const handleToggleBalanza = (id: number) => { + setSelectedBalanzas((prev) => + prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id] + ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (selectedBalanzas.length === 0) { + toast.error('Selecciona al menos una balanza'); + return; + } + + setLoading(true); + + try { + await reportesApi.create(clienteId, nombre, selectedBalanzas); + onSuccess(); + } catch { + toast.error('Error al generar reporte'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setNombre(e.target.value)} + className="input" + placeholder="Ej: Reporte Anual 2024" + required + /> +
+ +
+ +
+ {balanzas.map((balanza) => ( + + ))} +
+

+ {selectedBalanzas.length} balanza(s) seleccionada(s) +

+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/forms/UploadBalanza.tsx b/frontend/src/components/forms/UploadBalanza.tsx new file mode 100644 index 0000000..773454e --- /dev/null +++ b/frontend/src/components/forms/UploadBalanza.tsx @@ -0,0 +1,130 @@ +import { useState, useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { balanzasApi } from '../../services/api'; +import toast from 'react-hot-toast'; + +interface Props { + clienteId: number; + onSuccess: () => void; + onCancel: () => void; +} + +export default function UploadBalanza({ clienteId, onSuccess, onCancel }: Props) { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + periodo_inicio: '', + periodo_fin: '', + }); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + setFile(acceptedFiles[0]); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'application/pdf': ['.pdf'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'], + 'text/csv': ['.csv'], + }, + maxFiles: 1, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) { + toast.error('Selecciona un archivo'); + return; + } + + setLoading(true); + + try { + const data = new FormData(); + data.append('archivo', file); + data.append('periodo_inicio', formData.periodo_inicio); + data.append('periodo_fin', formData.periodo_fin); + + await balanzasApi.upload(clienteId, data); + onSuccess(); + } catch { + toast.error('Error al subir balanza'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Dropzone */} +
+ + {file ? ( +
+

{file.name}

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+ ) : isDragActive ? ( +

Suelta el archivo aquí...

+ ) : ( +
+

+ Arrastra un archivo aquí o haz clic para seleccionar +

+

+ PDF, Excel o CSV (máx. 10MB) +

+
+ )} +
+ + {/* Fechas */} +
+
+ + setFormData({ ...formData, periodo_inicio: e.target.value })} + className="input" + required + /> +
+
+ + setFormData({ ...formData, periodo_fin: e.target.value })} + className="input" + required + /> +
+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..01b778c --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,32 @@ +import { useAuth } from '../../context/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +export default function Header() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + return ( +
+
+ {/* Breadcrumb or page title could go here */} +
+ +
+ + {user?.email} + + +
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..978f614 --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,17 @@ +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import Header from './Header'; + +export default function Layout() { + return ( +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..5a8e0e6 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,95 @@ +import { NavLink } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import clsx from 'clsx'; + +const navItems = [ + { name: 'Clientes', path: '/clientes', icon: '🏢', roles: ['admin', 'analista'] }, + { name: 'Mi Empresa', path: '/clientes', icon: '📊', roles: ['cliente', 'empleado'] }, +]; + +const adminItems = [ + { name: 'Usuarios', path: '/admin/usuarios', icon: '👥' }, + { name: 'Giros', path: '/admin/giros', icon: '🏷️' }, + { name: 'Umbrales', path: '/admin/umbrales', icon: '📏' }, + { name: 'Reglas Mapeo', path: '/admin/reglas-mapeo', icon: '🔗' }, +]; + +export default function Sidebar() { + const { user, isAdmin } = useAuth(); + + const filteredNavItems = navItems.filter( + (item) => item.roles.includes(user?.role || '') + ); + + return ( + + ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..af83b62 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { User } from '../types'; +import { authApi } from '../services/api'; + +interface AuthContextType { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + isAdmin: boolean; + isAnalista: boolean; + isCliente: boolean; + isEmpleado: boolean; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) { + authApi.getUser() + .then(setUser) + .catch(() => localStorage.removeItem('token')) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const login = async (email: string, password: string) => { + const { user } = await authApi.login(email, password); + setUser(user); + }; + + const logout = async () => { + await authApi.logout(); + setUser(null); + }; + + const value: AuthContextType = { + user, + loading, + login, + logout, + isAdmin: user?.role === 'admin', + isAnalista: user?.role === 'analista', + isCliente: user?.role === 'cliente', + isEmpleado: user?.role === 'empleado', + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..d4b4e01 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,83 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + line-height: 1.5; + font-weight: 400; + color: #213547; + background-color: #f8fafc; +} + +body { + margin: 0; + min-height: 100vh; +} + +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700; + } + + .btn-secondary { + @apply bg-gray-200 text-gray-800 hover:bg-gray-300; + } + + .btn-danger { + @apply bg-red-600 text-white hover:bg-red-700; + } + + .input { + @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none; + } + + .card { + @apply bg-white rounded-xl shadow-sm border border-gray-100 p-6; + } + + .label { + @apply block text-sm font-medium text-gray-700 mb-1; + } +} + +/* Colores para tendencias/semáforos */ +.tendencia-muy-positivo { + @apply bg-emerald-500 text-white; +} + +.tendencia-positivo { + @apply bg-emerald-300 text-emerald-900; +} + +.tendencia-neutral { + @apply bg-amber-400 text-amber-900; +} + +.tendencia-negativo { + @apply bg-orange-500 text-white; +} + +.tendencia-muy-negativo { + @apply bg-red-500 text-white; +} + +/* Estilos para impresión/PDF */ +@media print { + .no-print { + display: none !important; + } + + .page-break { + page-break-after: always; + } + + body { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Admin/Giros.tsx b/frontend/src/pages/Admin/Giros.tsx new file mode 100644 index 0000000..2e3482f --- /dev/null +++ b/frontend/src/pages/Admin/Giros.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import { adminApi } from '../../services/api'; +import { Giro } from '../../types'; +import toast from 'react-hot-toast'; + +export default function AdminGiros() { + const [giros, setGiros] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ nombre: '', activo: true }); + const [editingId, setEditingId] = useState(null); + + useEffect(() => { + loadGiros(); + }, []); + + const loadGiros = async () => { + try { + const data = await adminApi.giros.list(); + setGiros(data); + } catch { + toast.error('Error al cargar giros'); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (editingId) { + await adminApi.giros.update(editingId, formData); + toast.success('Giro actualizado'); + } else { + await adminApi.giros.create(formData); + toast.success('Giro creado'); + } + setShowForm(false); + setEditingId(null); + setFormData({ nombre: '', activo: true }); + loadGiros(); + } catch { + toast.error('Error al guardar giro'); + } + }; + + const handleEdit = (giro: Giro) => { + setFormData({ nombre: giro.nombre, activo: giro.activo }); + setEditingId(giro.id); + setShowForm(true); + }; + + const handleDelete = async (id: number) => { + if (!confirm('¿Estás seguro de eliminar este giro?')) return; + try { + await adminApi.giros.delete(id); + toast.success('Giro eliminado'); + loadGiros(); + } catch { + toast.error('No se puede eliminar (tiene clientes asociados)'); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Giros de Negocio

+ +
+ +
+ + + + + + + + + + {giros.map((giro) => ( + + + + + + ))} + +
NombreEstadoAcciones
{giro.nombre} + + {giro.activo ? 'Activo' : 'Inactivo'} + + + + +
+
+ + {showForm && ( +
+
+

+ {editingId ? 'Editar Giro' : 'Nuevo Giro'} +

+
+
+ + setFormData({ ...formData, nombre: e.target.value })} + className="input" + required + /> +
+
+ +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Admin/ReglasMapeeo.tsx b/frontend/src/pages/Admin/ReglasMapeeo.tsx new file mode 100644 index 0000000..6ed190a --- /dev/null +++ b/frontend/src/pages/Admin/ReglasMapeeo.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from 'react'; +import { adminApi } from '../../services/api'; +import { ReglaMapeo } from '../../types'; +import toast from 'react-hot-toast'; + +export default function AdminReglasMapeeo() { + const [reglas, setReglas] = useState([]); + const [loading, setLoading] = useState(true); + const [filterSistema, setFilterSistema] = useState(''); + + const sistemas = ['contpaqi', 'aspel', 'sap', 'odoo', 'alegra', 'generico']; + + useEffect(() => { + loadReglas(); + }, []); + + const loadReglas = async () => { + try { + const data = await adminApi.reglasMapeeo.list(); + setReglas(data); + } catch { + toast.error('Error al cargar reglas'); + } finally { + setLoading(false); + } + }; + + const filteredReglas = filterSistema + ? reglas.filter((r) => r.sistema_origen === filterSistema) + : reglas; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Reglas de Mapeo Contable

+ +
+ + {/* Filtro */} +
+ +
+ +
+ + + + + + + + + + + + + + + {filteredReglas.map((regla) => ( + + + + + + + + + + + ))} + +
SistemaCuenta PadreRango / PatrónReporteCategoríaPrioridadActivoAcciones
{regla.sistema_origen} + {regla.cuenta_padre_codigo || '-'} + + {regla.patron_regex || + (regla.rango_inicio && regla.rango_fin + ? `${regla.rango_inicio} - ${regla.rango_fin}` + : '-')} + {regla.reporte_contable?.nombre}{regla.categoria_contable?.nombre}{regla.prioridad} + + {regla.activo ? 'Sí' : 'No'} + + + + +
+
+ +
+

Las reglas determinan cómo se clasifican las cuentas de cada sistema contable.

+

Las reglas con mayor prioridad se evalúan primero.

+
+
+ ); +} diff --git a/frontend/src/pages/Admin/Umbrales.tsx b/frontend/src/pages/Admin/Umbrales.tsx new file mode 100644 index 0000000..9b9593f --- /dev/null +++ b/frontend/src/pages/Admin/Umbrales.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react'; +import { adminApi } from '../../services/api'; +import { Umbral, Giro } from '../../types'; +import toast from 'react-hot-toast'; + +export default function AdminUmbrales() { + const [umbrales, setUmbrales] = useState([]); + const [giros, setGiros] = useState([]); + const [loading, setLoading] = useState(true); + const [filterGiro, setFilterGiro] = useState(''); + + useEffect(() => { + Promise.all([loadUmbrales(), loadGiros()]); + }, []); + + const loadUmbrales = async () => { + try { + const data = await adminApi.umbrales.list(); + setUmbrales(data); + } catch { + toast.error('Error al cargar umbrales'); + } finally { + setLoading(false); + } + }; + + const loadGiros = async () => { + try { + const data = await adminApi.giros.list(); + setGiros(data); + } catch { + toast.error('Error al cargar giros'); + } + }; + + const filteredUmbrales = filterGiro + ? umbrales.filter((u) => u.giro_id?.toString() === filterGiro || (!u.giro_id && filterGiro === 'general')) + : umbrales; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Umbrales de Métricas

+ +
+ + {/* Filtro */} +
+ +
+ +
+ + + + + + + + + + + + + + + {filteredUmbrales.map((umbral) => ( + + + + + + + + + + + ))} + +
MétricaGiroMuy ++Neutral-Muy -Acciones
{umbral.metrica} + {umbral.giro?.nombre || 'General'} + {umbral.muy_positivo ?? '-'}{umbral.positivo ?? '-'}{umbral.neutral ?? '-'}{umbral.negativo ?? '-'}{umbral.muy_negativo ?? '-'} + +
+
+ +
+

Los umbrales determinan el color del semáforo para cada métrica.

+

Los umbrales por giro tienen prioridad sobre los generales.

+
+
+ ); +} diff --git a/frontend/src/pages/Admin/Usuarios.tsx b/frontend/src/pages/Admin/Usuarios.tsx new file mode 100644 index 0000000..67c990f --- /dev/null +++ b/frontend/src/pages/Admin/Usuarios.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react'; +import { adminApi } from '../../services/api'; +import { User } from '../../types'; +import toast from 'react-hot-toast'; + +export default function AdminUsuarios() { + const [usuarios, setUsuarios] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + + useEffect(() => { + loadUsuarios(); + }, []); + + const loadUsuarios = async () => { + try { + const data = await adminApi.usuarios.list(); + setUsuarios(data); + } catch { + toast.error('Error al cargar usuarios'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm('¿Estás seguro de eliminar este usuario?')) return; + + try { + await adminApi.usuarios.delete(id); + toast.success('Usuario eliminado'); + loadUsuarios(); + } catch { + toast.error('Error al eliminar usuario'); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Usuarios

+ +
+ +
+ + + + + + + + + + + + {usuarios.map((usuario) => ( + + + + + + + + ))} + +
NombreEmailRolClienteAcciones
{usuario.nombre}{usuario.email} + + {usuario.role} + + + {usuario.cliente?.nombre_empresa || '-'} + + + +
+
+ + {/* Modal placeholder - implementar formulario completo */} + {(showForm || editingUser) && ( +
+
+

+ {editingUser ? 'Editar Usuario' : 'Nuevo Usuario'} +

+

Formulario de usuario aquí...

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Clientes/ClienteDetail.tsx b/frontend/src/pages/Clientes/ClienteDetail.tsx new file mode 100644 index 0000000..730e7df --- /dev/null +++ b/frontend/src/pages/Clientes/ClienteDetail.tsx @@ -0,0 +1,275 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { clientesApi, balanzasApi, reportesApi } from '../../services/api'; +import { Cliente, Balanza, Reporte } from '../../types'; +import toast from 'react-hot-toast'; +import UploadBalanza from '../../components/forms/UploadBalanza'; +import GenerarReporte from '../../components/forms/GenerarReporte'; + +export default function ClienteDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [cliente, setCliente] = useState(null); + const [balanzas, setBalanzas] = useState([]); + const [reportes, setReportes] = useState([]); + const [loading, setLoading] = useState(true); + const [showUpload, setShowUpload] = useState(false); + const [showGenerarReporte, setShowGenerarReporte] = useState(false); + + useEffect(() => { + if (id) { + loadData(parseInt(id)); + } + }, [id]); + + const loadData = async (clienteId: number) => { + try { + const [clienteData, balanzasData, reportesData] = await Promise.all([ + clientesApi.get(clienteId), + balanzasApi.list(clienteId), + reportesApi.list(clienteId), + ]); + setCliente(clienteData); + setBalanzas(balanzasData); + setReportes(reportesData); + } catch { + toast.error('Error al cargar datos del cliente'); + navigate('/clientes'); + } finally { + setLoading(false); + } + }; + + const handleBalanzaUploaded = () => { + setShowUpload(false); + if (id) loadData(parseInt(id)); + toast.success('Balanza subida exitosamente'); + }; + + const handleReporteGenerado = () => { + setShowGenerarReporte(false); + if (id) loadData(parseInt(id)); + toast.success('Reporte generado exitosamente'); + }; + + const handleDownloadPdf = async (reporteId: number, nombre: string) => { + try { + const blob = await reportesApi.downloadPdf(reporteId); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${nombre}.pdf`; + a.click(); + window.URL.revokeObjectURL(url); + } catch { + toast.error('Error al descargar PDF'); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!cliente) return null; + + const balanzasCompletadas = balanzas.filter((b) => b.status === 'completado'); + + return ( +
+ {/* Header */} +
+ +
+ {cliente.logo ? ( + {cliente.nombre_empresa} + ) : ( +
+ 🏢 +
+ )} +
+

{cliente.nombre_empresa}

+

{cliente.giro?.nombre} • {cliente.moneda}

+
+
+
+ +
+ {/* Balanzas */} +
+
+

Balanzas de Comprobación

+ +
+ + {balanzas.length === 0 ? ( +

+ No hay balanzas cargadas +

+ ) : ( +
+ {balanzas.map((balanza) => ( +
+
+

+ {new Date(balanza.periodo_fin).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + })} +

+

+ {balanza.sistema_origen} +

+
+ + {balanza.status} + +
+ ))} +
+ )} +
+ + {/* Reportes */} +
+
+

Reportes

+ {balanzasCompletadas.length >= 1 && ( + + )} +
+ + {reportes.length === 0 ? ( +

+ No hay reportes generados +

+ ) : ( +
+ {reportes.map((reporte) => ( +
+
+

{reporte.nombre}

+

+ {new Date(reporte.periodo_fin).toLocaleDateString('es-MX')} +

+
+
+ {reporte.status === 'completado' && ( + <> + + + + )} + + {reporte.status} + +
+
+ ))} +
+ )} +
+
+ + {/* Modales */} + {showUpload && cliente && ( +
+
+
+
+

Subir Balanza

+ +
+ setShowUpload(false)} + /> +
+
+
+ )} + + {showGenerarReporte && cliente && ( +
+
+
+
+

Generar Reporte

+ +
+ setShowGenerarReporte(false)} + /> +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Clientes/ClientesList.tsx b/frontend/src/pages/Clientes/ClientesList.tsx new file mode 100644 index 0000000..d88093f --- /dev/null +++ b/frontend/src/pages/Clientes/ClientesList.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { clientesApi } from '../../services/api'; +import { Cliente } from '../../types'; +import { useAuth } from '../../context/AuthContext'; +import toast from 'react-hot-toast'; +import ClienteForm from '../../components/forms/ClienteForm'; + +export default function ClientesList() { + const [clientes, setClientes] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const { isAdmin, isAnalista } = useAuth(); + + const canCreate = isAdmin || isAnalista; + + useEffect(() => { + loadClientes(); + }, []); + + const loadClientes = async () => { + try { + const data = await clientesApi.list(); + setClientes(data); + } catch { + toast.error('Error al cargar clientes'); + } finally { + setLoading(false); + } + }; + + const handleClienteCreated = () => { + setShowForm(false); + loadClientes(); + toast.success('Cliente creado exitosamente'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Clientes

+ {canCreate && ( + + )} +
+ + {clientes.length === 0 ? ( +
+

No hay clientes registrados

+ {canCreate && ( + + )} +
+ ) : ( +
+ {clientes.map((cliente) => ( + +
+ {cliente.logo ? ( + {cliente.nombre_empresa} + ) : ( +
+ 🏢 +
+ )} +
+

+ {cliente.nombre_empresa} +

+

+ {cliente.giro?.nombre || 'Sin giro'} +

+

+ Moneda: {cliente.moneda} +

+
+
+ + ))} +
+ )} + + {/* Modal de formulario */} + {showForm && ( +
+
+
+
+

Nuevo Cliente

+ +
+ setShowForm(false)} /> +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx new file mode 100644 index 0000000..af81dd4 --- /dev/null +++ b/frontend/src/pages/Dashboard/index.tsx @@ -0,0 +1,368 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { reportesApi, clientesApi } from '../../services/api'; +import { Reporte, Cliente } from '../../types'; +import toast from 'react-hot-toast'; +import KPICard from '../../components/cards/KPICard'; +import MetricTable from '../../components/cards/MetricTable'; +import BarChartComponent from '../../components/charts/BarChart'; +import LineChartComponent from '../../components/charts/LineChart'; + +export default function Dashboard() { + const { clienteId, reporteId } = useParams<{ clienteId: string; reporteId: string }>(); + const navigate = useNavigate(); + const [reporte, setReporte] = useState(null); + const [cliente, setCliente] = useState(null); + const [loading, setLoading] = useState(true); + const [activeSection, setActiveSection] = useState('resumen'); + + useEffect(() => { + if (clienteId && reporteId) { + loadData(parseInt(clienteId), parseInt(reporteId)); + } + }, [clienteId, reporteId]); + + const loadData = async (cId: number, rId: number) => { + try { + const [reporteData, clienteData] = await Promise.all([ + reportesApi.get(rId), + clientesApi.get(cId), + ]); + setReporte(reporteData); + setCliente(clienteData); + } catch { + toast.error('Error al cargar datos'); + navigate(`/clientes/${clienteId}`); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!reporte || !cliente || !reporte.data_calculada) { + return null; + } + + const { metricas, estados_financieros, comparativos, flujo_efectivo, periodos } = reporte.data_calculada; + const { balance_general, estado_resultados } = estados_financieros; + + const sections = [ + { id: 'resumen', name: 'Resumen' }, + { id: 'margenes', name: 'Márgenes' }, + { id: 'resultados', name: 'Resultados' }, + { id: 'balance', name: 'Balance' }, + { id: 'flujo', name: 'Flujo de Efectivo' }, + { id: 'metricas', name: 'Métricas' }, + { id: 'estados', name: 'Estados Financieros' }, + ]; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: cliente.moneda, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + const formatPercent = (value: number) => { + return `${(value * 100).toFixed(1)}%`; + }; + + return ( +
+ {/* Header */} +
+
+ +
+

{reporte.nombre}

+

+ {cliente.nombre_empresa} • {new Date(reporte.periodo_fin).toLocaleDateString('es-MX')} +

+
+
+ +
+ + {/* Navigation */} +
+ {sections.map((section) => ( + + ))} +
+ + {/* Content */} + {activeSection === 'resumen' && ( +
+ {/* KPIs principales */} +
+ + + + +
+ + {/* Resumen estados */} +
+
+

Balance General

+
+
+ Total Activos + {formatCurrency(balance_general.total_activos)} +
+
+ Total Pasivos + {formatCurrency(balance_general.total_pasivos)} +
+
+ Capital + + {formatCurrency(balance_general.total_capital)} + +
+
+
+ +
+

Estado de Resultados

+
+
+ Ingresos + {formatCurrency(estado_resultados.ingresos)} +
+
+ Utilidad Bruta + {formatCurrency(estado_resultados.utilidad_bruta)} +
+
+ Utilidad Operativa + {formatCurrency(estado_resultados.utilidad_operativa)} +
+
+ Utilidad Neta + + {formatCurrency(estado_resultados.utilidad_neta)} + +
+
+
+
+
+ )} + + {activeSection === 'margenes' && ( +
+
+ {['margen_bruto', 'margen_ebitda', 'margen_operativo', 'margen_neto'].map((key) => ( + + ))} +
+ + {periodos.length > 1 && ( +
+

Evolución de Márgenes

+ ({ + periodo: p.periodo, + 'Margen Bruto': (p.estado_resultados.utilidad_bruta / p.estado_resultados.ingresos) * 100, + 'Margen Operativo': (p.estado_resultados.utilidad_operativa / p.estado_resultados.ingresos) * 100, + 'Margen Neto': (p.estado_resultados.utilidad_neta / p.estado_resultados.ingresos) * 100, + }))} + lines={['Margen Bruto', 'Margen Operativo', 'Margen Neto']} + /> +
+ )} +
+ )} + + {activeSection === 'resultados' && ( +
+
+

Estado de Resultados

+ +
+
+ )} + + {activeSection === 'balance' && ( +
+
+
+

Activos

+ +
+
+

Pasivos y Capital

+ +
+
+
+ )} + + {activeSection === 'flujo' && ( +
+
+ + + + +
+
+ )} + + {activeSection === 'metricas' && ( +
+ + + +
+ )} + + {activeSection === 'estados' && ( +
+
+

Estados Financieros Detallados

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConceptoValor
Balance General
Activos Circulantes{formatCurrency(balance_general.activos_circulantes)}
Activos No Circulantes{formatCurrency(balance_general.activos_no_circulantes)}
Total Activos{formatCurrency(balance_general.total_activos)}
Pasivo Circulante{formatCurrency(balance_general.pasivo_circulante)}
Pasivo No Circulante{formatCurrency(balance_general.pasivo_no_circulante)}
Total Pasivos{formatCurrency(balance_general.total_pasivos)}
Capital{formatCurrency(balance_general.total_capital)}
Estado de Resultados
Ingresos{formatCurrency(estado_resultados.ingresos)}
Costo de Venta({formatCurrency(estado_resultados.costo_venta)})
Utilidad Bruta{formatCurrency(estado_resultados.utilidad_bruta)}
Gastos Operativos({formatCurrency(estado_resultados.gastos_operativos)})
Utilidad Operativa{formatCurrency(estado_resultados.utilidad_operativa)}
Otros Gastos({formatCurrency(estado_resultados.otros_gastos)})
Gastos Financieros({formatCurrency(estado_resultados.gastos_financieros)})
Utilidad Antes de Impuestos{formatCurrency(estado_resultados.utilidad_antes_impuestos)}
Impuestos({formatCurrency(estado_resultados.impuestos)})
Utilidad Neta{formatCurrency(estado_resultados.utilidad_neta)}
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..667c914 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import toast from 'react-hot-toast'; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + await login(email, password); + toast.success('Bienvenido'); + navigate('/'); + } catch { + toast.error('Credenciales incorrectas'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Horux Strategy

+

Plataforma de Reportes Financieros

+
+ +
+
+ + setEmail(e.target.value)} + className="input" + placeholder="correo@empresa.com" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="input" + placeholder="••••••••" + required + /> +
+ + +
+ +

+ ¿Necesitas ayuda? Contacta al administrador +

+
+
+ ); +} diff --git a/frontend/src/pages/PdfView/index.tsx b/frontend/src/pages/PdfView/index.tsx new file mode 100644 index 0000000..077e784 --- /dev/null +++ b/frontend/src/pages/PdfView/index.tsx @@ -0,0 +1,278 @@ +import { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { reportesApi } from '../../services/api'; +import { Reporte } from '../../types'; + +/** + * Vista especial para renderizado de PDF + * Esta página es capturada por Browsershot para generar el PDF + */ +export default function PdfView() { + const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const [reporte, setReporte] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!id || !token) { + setError('Acceso no autorizado'); + setLoading(false); + return; + } + + // Validar token y cargar reporte + loadReporte(parseInt(id)); + }, [id, searchParams]); + + const loadReporte = async (reporteId: number) => { + try { + const data = await reportesApi.get(reporteId); + setReporte(data); + } catch { + setError('Error al cargar reporte'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

Cargando reporte...

+
+
+ ); + } + + if (error || !reporte || !reporte.data_calculada) { + return ( +
+

{error || 'Reporte no disponible'}

+
+ ); + } + + const { metricas, estados_financieros } = reporte.data_calculada; + const { balance_general, estado_resultados } = estados_financieros; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + minimumFractionDigits: 0, + }).format(value); + }; + + const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`; + + return ( +
+ {/* Portada */} +
+

Horux Strategy

+

{reporte.nombre}

+

+ {new Date(reporte.periodo_fin).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'long', + })} +

+
+ + {/* Resumen */} +
+

Mensajes Destacados

+ +
+
+

Ingresos

+

{formatCurrency(estado_resultados.ingresos)}

+
+
+

Utilidad Neta

+

+ {formatCurrency(estado_resultados.utilidad_neta)} +

+
+
+

Margen EBITDA

+

{formatPercent(metricas.margen_ebitda?.valor || 0)}

+
+
+

ROE

+

{formatPercent(metricas.roe?.valor || 0)}

+
+
+
+ + {/* Balance General */} +
+

Balance General

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Activos Circulantes{formatCurrency(balance_general.activos_circulantes)}
Activos No Circulantes{formatCurrency(balance_general.activos_no_circulantes)}
Total Activos{formatCurrency(balance_general.total_activos)}
Pasivo Circulante{formatCurrency(balance_general.pasivo_circulante)}
Pasivo No Circulante{formatCurrency(balance_general.pasivo_no_circulante)}
Total Pasivos{formatCurrency(balance_general.total_pasivos)}
Capital{formatCurrency(balance_general.total_capital)}
+
+ + {/* Estado de Resultados */} +
+

Estado de Resultados

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Ingresos{formatCurrency(estado_resultados.ingresos)}
Costo de Venta({formatCurrency(estado_resultados.costo_venta)})
Utilidad Bruta{formatCurrency(estado_resultados.utilidad_bruta)}
Gastos Operativos({formatCurrency(estado_resultados.gastos_operativos)})
Utilidad Operativa{formatCurrency(estado_resultados.utilidad_operativa)}
Otros Gastos({formatCurrency(estado_resultados.otros_gastos)})
Gastos Financieros({formatCurrency(estado_resultados.gastos_financieros)})
Utilidad Antes de Impuestos{formatCurrency(estado_resultados.utilidad_antes_impuestos)}
Impuestos({formatCurrency(estado_resultados.impuestos)})
Utilidad Neta{formatCurrency(estado_resultados.utilidad_neta)}
+
+ + {/* Métricas */} +
+

Métricas Financieras

+ +
+
+

Márgenes

+ + + {['margen_bruto', 'margen_ebitda', 'margen_operativo', 'margen_neto'].map((key) => ( + + + + + ))} + +
{metricas[key]?.nombre || key}{formatPercent(metricas[key]?.valor || 0)}
+
+ +
+

Retorno

+ + + {['roic', 'roe', 'roa', 'roce'].map((key) => ( + + + + + ))} + +
{metricas[key]?.nombre || key}{formatPercent(metricas[key]?.valor || 0)}
+
+ +
+

Liquidez

+ + + {['current_ratio', 'quick_ratio', 'cash_ratio'].map((key) => ( + + + + + ))} + +
{metricas[key]?.nombre || key}{(metricas[key]?.valor || 0).toFixed(2)}
+
+ +
+

Solvencia

+ + + {['net_debt_ebitda', 'interest_coverage', 'debt_ratio'].map((key) => ( + + + + + ))} + +
{metricas[key]?.nombre || key} + {key === 'debt_ratio' + ? formatPercent(metricas[key]?.valor || 0) + : (metricas[key]?.valor || 0).toFixed(2)} +
+
+
+
+ + {/* Contraportada */} +
+

Horux Strategy

+

Reportes Financieros Inteligentes

+

+ Generado el {new Date().toLocaleDateString('es-MX')} +

+
+
+ ); +} diff --git a/frontend/src/pages/ReporteView.tsx b/frontend/src/pages/ReporteView.tsx new file mode 100644 index 0000000..8d85437 --- /dev/null +++ b/frontend/src/pages/ReporteView.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; + +export default function ReporteView() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + useEffect(() => { + // Redirigir al dashboard del reporte + if (id) { + // En una implementación completa, cargaríamos el reporte + // y redirigiríamos al dashboard correcto + navigate(`/clientes`); + } + }, [id, navigate]); + + return ( +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..29774b8 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,284 @@ +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import type { + AuthResponse, + User, + Cliente, + Balanza, + Cuenta, + Reporte, + Giro, + Umbral, + ReglaMapeo, + ReporteContable, + CategoriaContable +} from '../types'; + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + withCredentials: true, +}); + +// Interceptor para agregar token +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem('token'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Interceptor para manejar errores +api.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +// ========== AUTH ========== +export const authApi = { + login: async (email: string, password: string): Promise => { + const { data } = await api.post('/login', { email, password }); + localStorage.setItem('token', data.token); + return data; + }, + + logout: async (): Promise => { + await api.post('/logout'); + localStorage.removeItem('token'); + }, + + getUser: async (): Promise => { + const { data } = await api.get('/user'); + return data; + }, +}; + +// ========== GIROS ========== +export const girosApi = { + list: async (): Promise => { + const { data } = await api.get('/giros'); + return data; + }, +}; + +// ========== CLIENTES ========== +export const clientesApi = { + list: async (): Promise => { + const { data } = await api.get('/clientes'); + return data; + }, + + get: async (id: number): Promise => { + const { data } = await api.get(`/clientes/${id}`); + return data; + }, + + create: async (formData: FormData): Promise => { + const { data } = await api.post('/clientes', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; + }, + + update: async (id: number, formData: FormData): Promise => { + const { data } = await api.post(`/clientes/${id}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/clientes/${id}`); + }, +}; + +// ========== BALANZAS ========== +export const balanzasApi = { + list: async (clienteId: number): Promise => { + const { data } = await api.get(`/clientes/${clienteId}/balanzas`); + return data; + }, + + upload: async (clienteId: number, formData: FormData): Promise => { + const { data } = await api.post(`/clientes/${clienteId}/balanzas`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return data; + }, + + get: async (id: number): Promise => { + const { data } = await api.get(`/balanzas/${id}`); + return data; + }, + + getCuentas: async (id: number): Promise => { + const { data } = await api.get(`/balanzas/${id}/cuentas`); + return data; + }, + + updateExclusiones: async (id: number, exclusiones: number[]): Promise => { + await api.put(`/balanzas/${id}/exclusiones`, { exclusiones }); + }, +}; + +// ========== CUENTAS ========== +export const cuentasApi = { + updateClasificacion: async ( + id: number, + data: { + reporte_contable_id: number; + categoria_contable_id: number; + requiere_revision?: boolean; + nota_revision?: string; + } + ): Promise => { + const { data: cuenta } = await api.put(`/cuentas/${id}/clasificacion`, data); + return cuenta; + }, + + toggleExclusion: async (id: number): Promise => { + const { data } = await api.post(`/cuentas/${id}/toggle-exclusion`); + return data; + }, + + getAnomalias: async (): Promise => { + const { data } = await api.get('/anomalias'); + return data; + }, +}; + +// ========== REPORTES ========== +export const reportesApi = { + list: async (clienteId: number): Promise => { + const { data } = await api.get(`/clientes/${clienteId}/reportes`); + return data; + }, + + create: async (clienteId: number, nombre: string, balanzaIds: number[]): Promise => { + const { data } = await api.post(`/clientes/${clienteId}/reportes`, { + nombre, + balanza_ids: balanzaIds, + }); + return data; + }, + + get: async (id: number): Promise => { + const { data } = await api.get(`/reportes/${id}`); + return data; + }, + + downloadPdf: async (id: number): Promise => { + const { data } = await api.get(`/reportes/${id}/pdf`, { + responseType: 'blob', + }); + return data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/reportes/${id}`); + }, +}; + +// ========== ADMIN ========== +export const adminApi = { + // Usuarios + usuarios: { + list: async (): Promise => { + const { data } = await api.get('/admin/usuarios'); + return data; + }, + create: async (userData: Partial & { password: string }): Promise => { + const { data } = await api.post('/admin/usuarios', userData); + return data; + }, + update: async (id: number, userData: Partial): Promise => { + const { data } = await api.put(`/admin/usuarios/${id}`, userData); + return data; + }, + delete: async (id: number): Promise => { + await api.delete(`/admin/usuarios/${id}`); + }, + }, + + // Giros + giros: { + list: async (): Promise => { + const { data } = await api.get('/admin/giros'); + return data; + }, + create: async (giro: Partial): Promise => { + const { data } = await api.post('/admin/giros', giro); + return data; + }, + update: async (id: number, giro: Partial): Promise => { + const { data } = await api.put(`/admin/giros/${id}`, giro); + return data; + }, + delete: async (id: number): Promise => { + await api.delete(`/admin/giros/${id}`); + }, + }, + + // Umbrales + umbrales: { + list: async (giroId?: number): Promise => { + const params = giroId ? { giro_id: giroId } : {}; + const { data } = await api.get('/admin/umbrales', { params }); + return data; + }, + create: async (umbral: Partial): Promise => { + const { data } = await api.post('/admin/umbrales', umbral); + return data; + }, + update: async (id: number, umbral: Partial): Promise => { + const { data } = await api.put(`/admin/umbrales/${id}`, umbral); + return data; + }, + delete: async (id: number): Promise => { + await api.delete(`/admin/umbrales/${id}`); + }, + }, + + // Reglas de mapeo + reglasMapeeo: { + list: async (sistemaOrigen?: string): Promise => { + const params = sistemaOrigen ? { sistema_origen: sistemaOrigen } : {}; + const { data } = await api.get('/admin/reglas-mapeo', { params }); + return data; + }, + create: async (regla: Partial): Promise => { + const { data } = await api.post('/admin/reglas-mapeo', regla); + return data; + }, + update: async (id: number, regla: Partial): Promise => { + const { data } = await api.put(`/admin/reglas-mapeo/${id}`, regla); + return data; + }, + delete: async (id: number): Promise => { + await api.delete(`/admin/reglas-mapeo/${id}`); + }, + }, + + // Catálogos + catalogos: { + reportesContables: async (): Promise => { + const { data } = await api.get('/admin/reportes-contables'); + return data; + }, + categoriasContables: async (): Promise => { + const { data } = await api.get('/admin/categorias-contables'); + return data; + }, + }, +}; + +export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..0650d65 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,197 @@ +// Tipos de usuario y autenticación +export type UserRole = 'admin' | 'analista' | 'cliente' | 'empleado'; + +export interface User { + id: number; + nombre: string; + email: string; + role: UserRole; + cliente_id: number | null; + cliente?: Cliente; +} + +export interface AuthResponse { + user: User; + token: string; +} + +// Tipos de negocio +export interface Giro { + id: number; + nombre: string; + activo: boolean; +} + +export interface Cliente { + id: number; + nombre_empresa: string; + logo: string | null; + giro_id: number; + giro?: Giro; + moneda: string; + configuracion: Record | null; +} + +export interface Balanza { + id: number; + cliente_id: number; + periodo_inicio: string; + periodo_fin: string; + sistema_origen: string; + archivo_original: string; + status: 'pendiente' | 'procesando' | 'completado' | 'error'; + error_mensaje: string | null; +} + +export interface Cuenta { + id: number; + balanza_id: number; + codigo: string; + nombre: string; + nivel: number; + reporte_contable_id: number | null; + categoria_contable_id: number | null; + cuenta_padre_id: number | null; + saldo_inicial_deudor: number; + saldo_inicial_acreedor: number; + cargos: number; + abonos: number; + saldo_final_deudor: number; + saldo_final_acreedor: number; + excluida: boolean; + es_cuenta_padre: boolean; + requiere_revision: boolean; + nota_revision: string | null; + categoria_contable?: CategoriaContable; + reporte_contable?: ReporteContable; +} + +export interface ReporteContable { + id: number; + nombre: string; +} + +export interface CategoriaContable { + id: number; + reporte_contable_id: number; + nombre: string; + orden: number; +} + +export interface Reporte { + id: number; + cliente_id: number; + nombre: string; + periodo_tipo: 'mensual' | 'trimestral' | 'anual'; + periodo_inicio: string; + periodo_fin: string; + fecha_generacion: string | null; + data_calculada: DataCalculada | null; + pdf_path: string | null; + status: 'pendiente' | 'procesando' | 'completado' | 'error'; + cliente?: Cliente; + balanzas?: Balanza[]; +} + +// Tipos de métricas y cálculos +export type Tendencia = 'muy_positivo' | 'positivo' | 'neutral' | 'negativo' | 'muy_negativo'; + +export interface Metrica { + nombre: string; + valor: number; + valor_porcentaje: number; + tendencia: Tendencia; +} + +export interface BalanceGeneral { + activos_circulantes: number; + activos_no_circulantes: number; + total_activos: number; + pasivo_circulante: number; + pasivo_no_circulante: number; + total_pasivos: number; + capital_social: number; + utilidades_anteriores: number; + perdidas_anteriores: number; + total_capital: number; +} + +export interface EstadoResultados { + ingresos: number; + costo_venta: number; + utilidad_bruta: number; + gastos_operativos: number; + utilidad_operativa: number; + otros_gastos: number; + gastos_financieros: number; + utilidad_antes_impuestos: number; + impuestos: number; + utilidad_neta: number; +} + +export interface FlujoEfectivo { + metodo: string; + flujo_operacion: number; + flujo_inversion: number; + flujo_financiamiento: number; + flujo_neto: number; + detalle?: { + utilidad_neta: number; + depreciacion: number; + cambio_capital_trabajo: number; + }; +} + +export interface Comparativo { + valor_actual: number; + valor_anterior: number; + variacion_absoluta: number; + variacion_porcentual: number; + promedio_3_periodos?: number; +} + +export interface PeriodoData { + periodo: string; + balance_general: BalanceGeneral; + estado_resultados: EstadoResultados; +} + +export interface DataCalculada { + periodos: PeriodoData[]; + metricas: Record; + comparativos: Record; + flujo_efectivo: FlujoEfectivo; + estados_financieros: { + balance_general: BalanceGeneral; + estado_resultados: EstadoResultados; + flujo_efectivo: FlujoEfectivo; + }; +} + +// Tipos de administración +export interface Umbral { + id: number; + metrica: string; + muy_positivo: number | null; + positivo: number | null; + neutral: number | null; + negativo: number | null; + muy_negativo: number | null; + giro_id: number | null; + giro?: Giro; +} + +export interface ReglaMapeo { + id: number; + sistema_origen: string; + cuenta_padre_codigo: string | null; + rango_inicio: string | null; + rango_fin: string | null; + patron_regex: string | null; + reporte_contable_id: number; + categoria_contable_id: number; + prioridad: number; + activo: boolean; + reporte_contable?: ReporteContable; + categoria_contable?: CategoriaContable; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..454930b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,42 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + horux: { + dark: '#1a1a2e', + primary: '#16213e', + accent: '#0f3460', + highlight: '#e94560', + }, + status: { + 'muy-positivo': '#10b981', + 'positivo': '#34d399', + 'neutral': '#fbbf24', + 'negativo': '#f97316', + 'muy-negativo': '#ef4444', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5413626 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1406734 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +})