Initial commit: Horux Strategy Platform
- Laravel 11 backend with API REST - React 18 + TypeScript + Vite frontend - Multi-parser architecture for accounting systems (CONTPAQi, Aspel, SAP) - 27+ financial metrics calculation - PDF report generation with Browsershot - Complete documentation (10 documents) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
62
backend/app/Http/Controllers/Admin/GiroController.php
Normal file
62
backend/app/Http/Controllers/Admin/GiroController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Giro;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GiroController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json(Giro::all());
|
||||
}
|
||||
|
||||
public function activos(): JsonResponse
|
||||
{
|
||||
return response()->json(Giro::where('activo', true)->get());
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'required|string|max:255|unique:giros,nombre',
|
||||
'activo' => 'boolean',
|
||||
]);
|
||||
|
||||
$giro = Giro::create($validated);
|
||||
|
||||
return response()->json($giro, 201);
|
||||
}
|
||||
|
||||
public function show(Giro $giro): JsonResponse
|
||||
{
|
||||
return response()->json($giro);
|
||||
}
|
||||
|
||||
public function update(Request $request, Giro $giro): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'string|max:255|unique:giros,nombre,' . $giro->id,
|
||||
'activo' => 'boolean',
|
||||
]);
|
||||
|
||||
$giro->update($validated);
|
||||
|
||||
return response()->json($giro);
|
||||
}
|
||||
|
||||
public function destroy(Giro $giro): JsonResponse
|
||||
{
|
||||
if ($giro->clientes()->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'No se puede eliminar un giro con clientes asociados'
|
||||
], 422);
|
||||
}
|
||||
|
||||
$giro->delete();
|
||||
return response()->json(['message' => 'Giro eliminado']);
|
||||
}
|
||||
}
|
||||
71
backend/app/Http/Controllers/Admin/ReglaMapeeoController.php
Normal file
71
backend/app/Http/Controllers/Admin/ReglaMapeeoController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ReglaMapeo;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReglaMapeeoController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = ReglaMapeo::with(['reporteContable', 'categoriaContable']);
|
||||
|
||||
if ($request->has('sistema_origen')) {
|
||||
$query->where('sistema_origen', $request->sistema_origen);
|
||||
}
|
||||
|
||||
return response()->json($query->orderBy('prioridad', 'desc')->get());
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'sistema_origen' => 'required|string|max:50',
|
||||
'cuenta_padre_codigo' => 'nullable|string|max:20',
|
||||
'rango_inicio' => 'nullable|string|max:20',
|
||||
'rango_fin' => 'nullable|string|max:20',
|
||||
'patron_regex' => 'nullable|string|max:255',
|
||||
'reporte_contable_id' => 'required|exists:reportes_contables,id',
|
||||
'categoria_contable_id' => 'required|exists:categorias_contables,id',
|
||||
'prioridad' => 'integer',
|
||||
'activo' => 'boolean',
|
||||
]);
|
||||
|
||||
$regla = ReglaMapeo::create($validated);
|
||||
|
||||
return response()->json($regla->load(['reporteContable', 'categoriaContable']), 201);
|
||||
}
|
||||
|
||||
public function show(ReglaMapeo $reglaMapeo): JsonResponse
|
||||
{
|
||||
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
|
||||
}
|
||||
|
||||
public function update(Request $request, ReglaMapeo $reglaMapeo): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'sistema_origen' => 'string|max:50',
|
||||
'cuenta_padre_codigo' => 'nullable|string|max:20',
|
||||
'rango_inicio' => 'nullable|string|max:20',
|
||||
'rango_fin' => 'nullable|string|max:20',
|
||||
'patron_regex' => 'nullable|string|max:255',
|
||||
'reporte_contable_id' => 'exists:reportes_contables,id',
|
||||
'categoria_contable_id' => 'exists:categorias_contables,id',
|
||||
'prioridad' => 'integer',
|
||||
'activo' => 'boolean',
|
||||
]);
|
||||
|
||||
$reglaMapeo->update($validated);
|
||||
|
||||
return response()->json($reglaMapeo->load(['reporteContable', 'categoriaContable']));
|
||||
}
|
||||
|
||||
public function destroy(ReglaMapeo $reglaMapeo): JsonResponse
|
||||
{
|
||||
$reglaMapeo->delete();
|
||||
return response()->json(['message' => 'Regla de mapeo eliminada']);
|
||||
}
|
||||
}
|
||||
87
backend/app/Http/Controllers/Admin/UmbralController.php
Normal file
87
backend/app/Http/Controllers/Admin/UmbralController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Umbral;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UmbralController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Umbral::with('giro');
|
||||
|
||||
if ($request->has('giro_id')) {
|
||||
$query->where('giro_id', $request->giro_id);
|
||||
}
|
||||
|
||||
return response()->json($query->get());
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'metrica' => 'required|string|max:100',
|
||||
'muy_positivo' => 'nullable|numeric',
|
||||
'positivo' => 'nullable|numeric',
|
||||
'neutral' => 'nullable|numeric',
|
||||
'negativo' => 'nullable|numeric',
|
||||
'muy_negativo' => 'nullable|numeric',
|
||||
'giro_id' => 'nullable|exists:giros,id',
|
||||
]);
|
||||
|
||||
$umbral = Umbral::create($validated);
|
||||
|
||||
return response()->json($umbral->load('giro'), 201);
|
||||
}
|
||||
|
||||
public function show(Umbral $umbral): JsonResponse
|
||||
{
|
||||
return response()->json($umbral->load('giro'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Umbral $umbral): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'metrica' => 'string|max:100',
|
||||
'muy_positivo' => 'nullable|numeric',
|
||||
'positivo' => 'nullable|numeric',
|
||||
'neutral' => 'nullable|numeric',
|
||||
'negativo' => 'nullable|numeric',
|
||||
'muy_negativo' => 'nullable|numeric',
|
||||
'giro_id' => 'nullable|exists:giros,id',
|
||||
]);
|
||||
|
||||
$umbral->update($validated);
|
||||
|
||||
return response()->json($umbral->load('giro'));
|
||||
}
|
||||
|
||||
public function destroy(Umbral $umbral): JsonResponse
|
||||
{
|
||||
$umbral->delete();
|
||||
return response()->json(['message' => 'Umbral eliminado']);
|
||||
}
|
||||
|
||||
public function porMetrica(string $metrica, ?int $giroId = null): JsonResponse
|
||||
{
|
||||
// Buscar primero umbral específico del giro, luego el genérico
|
||||
$umbral = Umbral::where('metrica', $metrica)
|
||||
->where('giro_id', $giroId)
|
||||
->first();
|
||||
|
||||
if (!$umbral) {
|
||||
$umbral = Umbral::where('metrica', $metrica)
|
||||
->whereNull('giro_id')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$umbral) {
|
||||
return response()->json(['message' => 'Umbral no encontrado'], 404);
|
||||
}
|
||||
|
||||
return response()->json($umbral);
|
||||
}
|
||||
}
|
||||
71
backend/app/Http/Controllers/Admin/UsuarioController.php
Normal file
71
backend/app/Http/Controllers/Admin/UsuarioController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class UsuarioController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$usuarios = User::with('cliente')->get();
|
||||
return response()->json($usuarios);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|in:admin,analista,cliente,empleado',
|
||||
'cliente_id' => 'nullable|exists:clientes,id',
|
||||
]);
|
||||
|
||||
$usuario = User::create([
|
||||
'nombre' => $validated['nombre'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'role' => $validated['role'],
|
||||
'cliente_id' => $validated['cliente_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($usuario->load('cliente'), 201);
|
||||
}
|
||||
|
||||
public function show(User $usuario): JsonResponse
|
||||
{
|
||||
return response()->json($usuario->load('cliente'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $usuario): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'string|max:255',
|
||||
'email' => 'email|unique:users,email,' . $usuario->id,
|
||||
'password' => 'nullable|string|min:8',
|
||||
'role' => 'in:admin,analista,cliente,empleado',
|
||||
'cliente_id' => 'nullable|exists:clientes,id',
|
||||
]);
|
||||
|
||||
if (isset($validated['password'])) {
|
||||
$validated['password'] = Hash::make($validated['password']);
|
||||
} else {
|
||||
unset($validated['password']);
|
||||
}
|
||||
|
||||
$usuario->update($validated);
|
||||
|
||||
return response()->json($usuario->load('cliente'));
|
||||
}
|
||||
|
||||
public function destroy(User $usuario): JsonResponse
|
||||
{
|
||||
$usuario->delete();
|
||||
return response()->json(['message' => 'Usuario eliminado']);
|
||||
}
|
||||
}
|
||||
74
backend/app/Http/Controllers/AuthController.php
Normal file
74
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['Las credenciales proporcionadas son incorrectas.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => $user->load('cliente'),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json(['message' => 'Sesión cerrada exitosamente']);
|
||||
}
|
||||
|
||||
public function user(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($request->user()->load('cliente'));
|
||||
}
|
||||
|
||||
public function register(Request $request): JsonResponse
|
||||
{
|
||||
// Solo admin puede registrar usuarios
|
||||
if (!$request->user()->isAdmin()) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|string|min:8',
|
||||
'role' => 'required|in:admin,analista,cliente,empleado',
|
||||
'cliente_id' => 'nullable|exists:clientes,id',
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'nombre' => $validated['nombre'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
'role' => $validated['role'],
|
||||
'cliente_id' => $validated['cliente_id'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($user, 201);
|
||||
}
|
||||
}
|
||||
143
backend/app/Http/Controllers/BalanzaController.php
Normal file
143
backend/app/Http/Controllers/BalanzaController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Balanza;
|
||||
use App\Models\Cliente;
|
||||
use App\Services\Parsers\DetectorFormato;
|
||||
use App\Services\ClasificadorCuentas;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BalanzaController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DetectorFormato $detector,
|
||||
private ClasificadorCuentas $clasificador,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$balanzas = $cliente->balanzas()
|
||||
->orderByDesc('periodo_fin')
|
||||
->get();
|
||||
|
||||
return response()->json($balanzas);
|
||||
}
|
||||
|
||||
public function store(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'archivo' => 'required|file|mimes:pdf,xlsx,xls,csv|max:10240',
|
||||
'periodo_inicio' => 'required|date',
|
||||
'periodo_fin' => 'required|date|after_or_equal:periodo_inicio',
|
||||
]);
|
||||
|
||||
$file = $request->file('archivo');
|
||||
$path = $file->store('balanzas/' . $cliente->id, 'local');
|
||||
|
||||
$balanza = Balanza::create([
|
||||
'cliente_id' => $cliente->id,
|
||||
'periodo_inicio' => $request->periodo_inicio,
|
||||
'periodo_fin' => $request->periodo_fin,
|
||||
'archivo_original' => $path,
|
||||
'sistema_origen' => 'pendiente',
|
||||
'status' => 'pendiente',
|
||||
]);
|
||||
|
||||
// Procesar archivo en background o inmediatamente
|
||||
try {
|
||||
$this->procesarBalanza($balanza, $path);
|
||||
} catch (\Exception $e) {
|
||||
$balanza->update([
|
||||
'status' => 'error',
|
||||
'error_mensaje' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($balanza, 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Balanza $balanza): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
return response()->json($balanza->load(['cuentas', 'cliente']));
|
||||
}
|
||||
|
||||
public function cuentas(Request $request, Balanza $balanza): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$cuentas = $balanza->cuentas()
|
||||
->with(['categoriaContable', 'reporteContable', 'cuentaPadre'])
|
||||
->orderBy('codigo')
|
||||
->get();
|
||||
|
||||
return response()->json($cuentas);
|
||||
}
|
||||
|
||||
public function updateExclusiones(Request $request, Balanza $balanza): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($balanza->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'exclusiones' => 'required|array',
|
||||
'exclusiones.*' => 'integer|exists:cuentas,id',
|
||||
]);
|
||||
|
||||
// Marcar todas como incluidas primero
|
||||
$balanza->cuentas()->update(['excluida' => false]);
|
||||
|
||||
// Marcar las seleccionadas como excluidas
|
||||
$balanza->cuentas()
|
||||
->whereIn('id', $request->exclusiones)
|
||||
->update(['excluida' => true]);
|
||||
|
||||
// Recalcular saldos de cuentas padre
|
||||
$cuentasPadre = $balanza->cuentasPadre()->get();
|
||||
foreach ($cuentasPadre as $cuentaPadre) {
|
||||
$cuentaPadre->recalcularSaldoDesdeHijos();
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Exclusiones actualizadas']);
|
||||
}
|
||||
|
||||
private function procesarBalanza(Balanza $balanza, string $path): void
|
||||
{
|
||||
$balanza->update(['status' => 'procesando']);
|
||||
|
||||
$fullPath = Storage::disk('local')->path($path);
|
||||
|
||||
// Detectar sistema origen
|
||||
$resultado = $this->detector->detectar($fullPath);
|
||||
$balanza->update(['sistema_origen' => $resultado['sistema']]);
|
||||
|
||||
// Parsear y guardar cuentas
|
||||
$cuentas = $resultado['parser']->parsear($fullPath);
|
||||
|
||||
foreach ($cuentas as $cuentaData) {
|
||||
$balanza->cuentas()->create($cuentaData);
|
||||
}
|
||||
|
||||
// Clasificar cuentas
|
||||
$this->clasificador->clasificar($balanza);
|
||||
|
||||
$balanza->update(['status' => 'completado']);
|
||||
}
|
||||
}
|
||||
99
backend/app/Http/Controllers/ClienteController.php
Normal file
99
backend/app/Http/Controllers/ClienteController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cliente;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ClienteController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->isAdmin() || $user->isAnalista()) {
|
||||
$clientes = Cliente::with('giro')->get();
|
||||
} else {
|
||||
$clientes = Cliente::where('id', $user->cliente_id)->with('giro')->get();
|
||||
}
|
||||
|
||||
return response()->json($clientes);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nombre_empresa' => 'required|string|max:255',
|
||||
'giro_id' => 'required|exists:giros,id',
|
||||
'moneda' => 'string|max:3',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
]);
|
||||
|
||||
$cliente = new Cliente([
|
||||
'nombre_empresa' => $validated['nombre_empresa'],
|
||||
'giro_id' => $validated['giro_id'],
|
||||
'moneda' => $validated['moneda'] ?? 'MXN',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$path = $request->file('logo')->store('logos', 'public');
|
||||
$cliente->logo = $path;
|
||||
}
|
||||
|
||||
$cliente->save();
|
||||
|
||||
return response()->json($cliente->load('giro'), 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
return response()->json($cliente->load(['giro', 'balanzas', 'reportes']));
|
||||
}
|
||||
|
||||
public function update(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'nombre_empresa' => 'string|max:255',
|
||||
'giro_id' => 'exists:giros,id',
|
||||
'moneda' => 'string|max:3',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'configuracion' => 'nullable|array',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
if ($cliente->logo) {
|
||||
Storage::disk('public')->delete($cliente->logo);
|
||||
}
|
||||
$validated['logo'] = $request->file('logo')->store('logos', 'public');
|
||||
}
|
||||
|
||||
$cliente->update($validated);
|
||||
|
||||
return response()->json($cliente->load('giro'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->isAdmin()) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
if ($cliente->logo) {
|
||||
Storage::disk('public')->delete($cliente->logo);
|
||||
}
|
||||
|
||||
$cliente->delete();
|
||||
|
||||
return response()->json(['message' => 'Cliente eliminado']);
|
||||
}
|
||||
}
|
||||
12
backend/app/Http/Controllers/Controller.php
Normal file
12
backend/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
65
backend/app/Http/Controllers/CuentaController.php
Normal file
65
backend/app/Http/Controllers/CuentaController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cuenta;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CuentaController extends Controller
|
||||
{
|
||||
public function updateClasificacion(Request $request, Cuenta $cuenta): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reporte_contable_id' => 'required|exists:reportes_contables,id',
|
||||
'categoria_contable_id' => 'required|exists:categorias_contables,id',
|
||||
'requiere_revision' => 'boolean',
|
||||
'nota_revision' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$cuenta->update([
|
||||
'reporte_contable_id' => $validated['reporte_contable_id'],
|
||||
'categoria_contable_id' => $validated['categoria_contable_id'],
|
||||
'requiere_revision' => $validated['requiere_revision'] ?? false,
|
||||
'nota_revision' => $validated['nota_revision'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($cuenta->load(['categoriaContable', 'reporteContable']));
|
||||
}
|
||||
|
||||
public function toggleExclusion(Request $request, Cuenta $cuenta): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cuenta->balanza->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$cuenta->update(['excluida' => !$cuenta->excluida]);
|
||||
|
||||
// Recalcular cuenta padre si existe
|
||||
if ($cuenta->cuentaPadre) {
|
||||
$cuenta->cuentaPadre->recalcularSaldoDesdeHijos();
|
||||
}
|
||||
|
||||
return response()->json($cuenta);
|
||||
}
|
||||
|
||||
public function anomalias(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$query = Cuenta::where('requiere_revision', true)
|
||||
->with(['balanza.cliente', 'categoriaContable', 'reporteContable']);
|
||||
|
||||
if (!$user->isAdmin() && !$user->isAnalista()) {
|
||||
$query->whereHas('balanza', function ($q) use ($user) {
|
||||
$q->where('cliente_id', $user->cliente_id);
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json($query->get());
|
||||
}
|
||||
}
|
||||
162
backend/app/Http/Controllers/ReporteController.php
Normal file
162
backend/app/Http/Controllers/ReporteController.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cliente;
|
||||
use App\Models\Reporte;
|
||||
use App\Services\CalculadorMetricas;
|
||||
use App\Services\GeneradorPdf;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class ReporteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CalculadorMetricas $calculador,
|
||||
private GeneradorPdf $generadorPdf,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$reportes = $cliente->reportes()
|
||||
->orderByDesc('periodo_fin')
|
||||
->get();
|
||||
|
||||
return response()->json($reportes);
|
||||
}
|
||||
|
||||
public function store(Request $request, Cliente $cliente): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($cliente->id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'nombre' => 'required|string|max:255',
|
||||
'balanza_ids' => 'required|array|min:1',
|
||||
'balanza_ids.*' => 'exists:balanzas,id',
|
||||
]);
|
||||
|
||||
// Verificar que las balanzas pertenecen al cliente
|
||||
$balanzasValidas = $cliente->balanzas()
|
||||
->whereIn('id', $validated['balanza_ids'])
|
||||
->where('status', 'completado')
|
||||
->get();
|
||||
|
||||
if ($balanzasValidas->count() !== count($validated['balanza_ids'])) {
|
||||
return response()->json([
|
||||
'message' => 'Algunas balanzas no son válidas o no están procesadas'
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Determinar tipo de periodo
|
||||
$periodoTipo = $this->determinarTipoPeriodo($balanzasValidas);
|
||||
|
||||
$reporte = Reporte::create([
|
||||
'cliente_id' => $cliente->id,
|
||||
'nombre' => $validated['nombre'],
|
||||
'periodo_tipo' => $periodoTipo,
|
||||
'periodo_inicio' => $balanzasValidas->min('periodo_inicio'),
|
||||
'periodo_fin' => $balanzasValidas->max('periodo_fin'),
|
||||
'status' => 'procesando',
|
||||
]);
|
||||
|
||||
$reporte->balanzas()->attach($validated['balanza_ids']);
|
||||
|
||||
// Calcular métricas
|
||||
try {
|
||||
$dataCalculada = $this->calculador->calcular($reporte);
|
||||
$reporte->update([
|
||||
'data_calculada' => $dataCalculada,
|
||||
'fecha_generacion' => now(),
|
||||
'status' => 'completado',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$reporte->update(['status' => 'error']);
|
||||
return response()->json(['message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json($reporte, 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Reporte $reporte): JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
return response()->json($reporte->load(['cliente', 'balanzas']));
|
||||
}
|
||||
|
||||
public function pdf(Request $request, Reporte $reporte): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
if (!$request->user()->canAccessCliente($reporte->cliente_id)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
// Verificar permisos de empleado si aplica
|
||||
$user = $request->user();
|
||||
if ($user->isEmpleado()) {
|
||||
$permiso = $user->permisosEmpleado()
|
||||
->where('cliente_id', $reporte->cliente_id)
|
||||
->first();
|
||||
|
||||
if (!$permiso || !$permiso->tienePermiso('exportar_pdf')) {
|
||||
return response()->json(['message' => 'No tiene permiso para exportar PDF'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$reporte->pdf_path || !file_exists(storage_path('app/' . $reporte->pdf_path))) {
|
||||
// Generar PDF
|
||||
$pdfPath = $this->generadorPdf->generar($reporte);
|
||||
$reporte->update(['pdf_path' => $pdfPath]);
|
||||
}
|
||||
|
||||
return response()->download(
|
||||
storage_path('app/' . $reporte->pdf_path),
|
||||
$reporte->nombre . '.pdf'
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Reporte $reporte): JsonResponse
|
||||
{
|
||||
if (!$request->user()->isAdmin() && !$request->user()->isAnalista()) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
$reporte->delete();
|
||||
|
||||
return response()->json(['message' => 'Reporte eliminado']);
|
||||
}
|
||||
|
||||
private function determinarTipoPeriodo($balanzas): string
|
||||
{
|
||||
if ($balanzas->count() < 2) {
|
||||
$balanza = $balanzas->first();
|
||||
$dias = $balanza->periodo_inicio->diffInDays($balanza->periodo_fin);
|
||||
|
||||
if ($dias <= 35) return 'mensual';
|
||||
if ($dias <= 100) return 'trimestral';
|
||||
return 'anual';
|
||||
}
|
||||
|
||||
// Calcular diferencia promedio entre periodos
|
||||
$diferencias = [];
|
||||
$sorted = $balanzas->sortBy('periodo_inicio')->values();
|
||||
|
||||
for ($i = 1; $i < $sorted->count(); $i++) {
|
||||
$diferencias[] = $sorted[$i - 1]->periodo_fin->diffInDays($sorted[$i]->periodo_inicio);
|
||||
}
|
||||
|
||||
$promedio = array_sum($diferencias) / count($diferencias);
|
||||
|
||||
if ($promedio <= 35) return 'mensual';
|
||||
if ($promedio <= 100) return 'trimestral';
|
||||
return 'anual';
|
||||
}
|
||||
}
|
||||
31
backend/app/Http/Middleware/RoleMiddleware.php
Normal file
31
backend/app/Http/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param string ...$roles
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'No autenticado'], 401);
|
||||
}
|
||||
|
||||
if (!in_array($user->role, $roles)) {
|
||||
return response()->json(['message' => 'No autorizado'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
57
backend/app/Models/Balanza.php
Normal file
57
backend/app/Models/Balanza.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Balanza extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'cliente_id',
|
||||
'periodo_inicio',
|
||||
'periodo_fin',
|
||||
'sistema_origen',
|
||||
'archivo_original',
|
||||
'status',
|
||||
'error_mensaje',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'periodo_inicio' => 'date',
|
||||
'periodo_fin' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function cuentas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class);
|
||||
}
|
||||
|
||||
public function reportes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Reporte::class, 'reporte_balanza');
|
||||
}
|
||||
|
||||
public function cuentasActivas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class)->where('excluida', false);
|
||||
}
|
||||
|
||||
public function cuentasPadre(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class)->where('es_cuenta_padre', true);
|
||||
}
|
||||
}
|
||||
36
backend/app/Models/CategoriaContable.php
Normal file
36
backend/app/Models/CategoriaContable.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CategoriaContable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'categorias_contables';
|
||||
|
||||
protected $fillable = [
|
||||
'reporte_contable_id',
|
||||
'nombre',
|
||||
'orden',
|
||||
];
|
||||
|
||||
public function reporteContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReporteContable::class);
|
||||
}
|
||||
|
||||
public function cuentas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class);
|
||||
}
|
||||
|
||||
public function reglasMapeoo(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReglaMapeoo::class);
|
||||
}
|
||||
}
|
||||
58
backend/app/Models/Cliente.php
Normal file
58
backend/app/Models/Cliente.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Cliente extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'nombre_empresa',
|
||||
'logo',
|
||||
'giro_id',
|
||||
'moneda',
|
||||
'configuracion',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'configuracion' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function giro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Giro::class);
|
||||
}
|
||||
|
||||
public function usuarios(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function balanzas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Balanza::class);
|
||||
}
|
||||
|
||||
public function reportes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Reporte::class);
|
||||
}
|
||||
|
||||
public function mapeoCuentas(): HasMany
|
||||
{
|
||||
return $this->hasMany(MapeoCuenta::class);
|
||||
}
|
||||
|
||||
public function permisosEmpleado(): HasMany
|
||||
{
|
||||
return $this->hasMany(PermisoEmpleado::class);
|
||||
}
|
||||
}
|
||||
109
backend/app/Models/Cuenta.php
Normal file
109
backend/app/Models/Cuenta.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Cuenta extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'balanza_id',
|
||||
'codigo',
|
||||
'nombre',
|
||||
'nivel',
|
||||
'reporte_contable_id',
|
||||
'categoria_contable_id',
|
||||
'cuenta_padre_id',
|
||||
'saldo_inicial_deudor',
|
||||
'saldo_inicial_acreedor',
|
||||
'cargos',
|
||||
'abonos',
|
||||
'saldo_final_deudor',
|
||||
'saldo_final_acreedor',
|
||||
'excluida',
|
||||
'es_cuenta_padre',
|
||||
'requiere_revision',
|
||||
'nota_revision',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'saldo_inicial_deudor' => 'decimal:2',
|
||||
'saldo_inicial_acreedor' => 'decimal:2',
|
||||
'cargos' => 'decimal:2',
|
||||
'abonos' => 'decimal:2',
|
||||
'saldo_final_deudor' => 'decimal:2',
|
||||
'saldo_final_acreedor' => 'decimal:2',
|
||||
'excluida' => 'boolean',
|
||||
'es_cuenta_padre' => 'boolean',
|
||||
'requiere_revision' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function balanza(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Balanza::class);
|
||||
}
|
||||
|
||||
public function reporteContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReporteContable::class);
|
||||
}
|
||||
|
||||
public function categoriaContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CategoriaContable::class);
|
||||
}
|
||||
|
||||
public function cuentaPadre(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cuenta::class, 'cuenta_padre_id');
|
||||
}
|
||||
|
||||
public function cuentasHijo(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class, 'cuenta_padre_id');
|
||||
}
|
||||
|
||||
public function getSaldoInicialNetoAttribute(): float
|
||||
{
|
||||
return $this->saldo_inicial_deudor - $this->saldo_inicial_acreedor;
|
||||
}
|
||||
|
||||
public function getSaldoFinalNetoAttribute(): float
|
||||
{
|
||||
return $this->saldo_final_deudor - $this->saldo_final_acreedor;
|
||||
}
|
||||
|
||||
public function getMovimientoNetoAttribute(): float
|
||||
{
|
||||
return $this->cargos - $this->abonos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcula el saldo considerando solo cuentas hijo activas
|
||||
*/
|
||||
public function recalcularSaldoDesdeHijos(): void
|
||||
{
|
||||
if (!$this->es_cuenta_padre) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hijosActivos = $this->cuentasHijo()->where('excluida', false)->get();
|
||||
|
||||
$this->saldo_inicial_deudor = $hijosActivos->sum('saldo_inicial_deudor');
|
||||
$this->saldo_inicial_acreedor = $hijosActivos->sum('saldo_inicial_acreedor');
|
||||
$this->cargos = $hijosActivos->sum('cargos');
|
||||
$this->abonos = $hijosActivos->sum('abonos');
|
||||
$this->saldo_final_deudor = $hijosActivos->sum('saldo_final_deudor');
|
||||
$this->saldo_final_acreedor = $hijosActivos->sum('saldo_final_acreedor');
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
34
backend/app/Models/Giro.php
Normal file
34
backend/app/Models/Giro.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Giro extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'nombre',
|
||||
'activo',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'activo' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function clientes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cliente::class);
|
||||
}
|
||||
|
||||
public function umbrales(): HasMany
|
||||
{
|
||||
return $this->hasMany(Umbral::class);
|
||||
}
|
||||
}
|
||||
31
backend/app/Models/MapeoCuenta.php
Normal file
31
backend/app/Models/MapeoCuenta.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MapeoCuenta extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'mapeo_cuentas';
|
||||
|
||||
protected $fillable = [
|
||||
'cliente_id',
|
||||
'codigo_patron',
|
||||
'categoria_contable_id',
|
||||
'notas',
|
||||
];
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function categoriaContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CategoriaContable::class);
|
||||
}
|
||||
}
|
||||
42
backend/app/Models/PermisoEmpleado.php
Normal file
42
backend/app/Models/PermisoEmpleado.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PermisoEmpleado extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'permisos_empleado';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'cliente_id',
|
||||
'permisos',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'permisos' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function tienePermiso(string $permiso): bool
|
||||
{
|
||||
return in_array($permiso, $this->permisos ?? []);
|
||||
}
|
||||
}
|
||||
73
backend/app/Models/ReglaMapeo.php
Normal file
73
backend/app/Models/ReglaMapeo.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReglaMapeo extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'reglas_mapeo';
|
||||
|
||||
protected $fillable = [
|
||||
'sistema_origen',
|
||||
'cuenta_padre_codigo',
|
||||
'rango_inicio',
|
||||
'rango_fin',
|
||||
'patron_regex',
|
||||
'reporte_contable_id',
|
||||
'categoria_contable_id',
|
||||
'prioridad',
|
||||
'activo',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'activo' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function reporteContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReporteContable::class);
|
||||
}
|
||||
|
||||
public function categoriaContable(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CategoriaContable::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un código de cuenta coincide con esta regla
|
||||
*/
|
||||
public function coincideCon(string $codigo): bool
|
||||
{
|
||||
// Si tiene patrón regex, usarlo
|
||||
if ($this->patron_regex) {
|
||||
return (bool) preg_match($this->patron_regex, $codigo);
|
||||
}
|
||||
|
||||
// Si tiene rango, verificar
|
||||
if ($this->rango_inicio && $this->rango_fin) {
|
||||
$codigoNumerico = $this->codigoANumero($codigo);
|
||||
$inicioNumerico = $this->codigoANumero($this->rango_inicio);
|
||||
$finNumerico = $this->codigoANumero($this->rango_fin);
|
||||
|
||||
return $codigoNumerico >= $inicioNumerico && $codigoNumerico <= $finNumerico;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte código tipo "001-100-000" a número para comparación
|
||||
*/
|
||||
private function codigoANumero(string $codigo): int
|
||||
{
|
||||
return (int) str_replace(['-', '.', ' '], '', $codigo);
|
||||
}
|
||||
}
|
||||
55
backend/app/Models/Reporte.php
Normal file
55
backend/app/Models/Reporte.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Reporte extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'cliente_id',
|
||||
'nombre',
|
||||
'periodo_tipo',
|
||||
'periodo_inicio',
|
||||
'periodo_fin',
|
||||
'fecha_generacion',
|
||||
'data_calculada',
|
||||
'pdf_path',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'periodo_inicio' => 'date',
|
||||
'periodo_fin' => 'date',
|
||||
'fecha_generacion' => 'datetime',
|
||||
'data_calculada' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function balanzas(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Balanza::class, 'reporte_balanza');
|
||||
}
|
||||
|
||||
public function getMetrica(string $nombre): ?array
|
||||
{
|
||||
return $this->data_calculada['metricas'][$nombre] ?? null;
|
||||
}
|
||||
|
||||
public function getEstadoFinanciero(string $tipo): ?array
|
||||
{
|
||||
return $this->data_calculada['estados_financieros'][$tipo] ?? null;
|
||||
}
|
||||
}
|
||||
28
backend/app/Models/ReporteContable.php
Normal file
28
backend/app/Models/ReporteContable.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ReporteContable extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'reportes_contables';
|
||||
|
||||
protected $fillable = [
|
||||
'nombre',
|
||||
];
|
||||
|
||||
public function categorias(): HasMany
|
||||
{
|
||||
return $this->hasMany(CategoriaContable::class)->orderBy('orden');
|
||||
}
|
||||
|
||||
public function cuentas(): HasMany
|
||||
{
|
||||
return $this->hasMany(Cuenta::class);
|
||||
}
|
||||
}
|
||||
57
backend/app/Models/Umbral.php
Normal file
57
backend/app/Models/Umbral.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Umbral extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'umbrales';
|
||||
|
||||
protected $fillable = [
|
||||
'metrica',
|
||||
'muy_positivo',
|
||||
'positivo',
|
||||
'neutral',
|
||||
'negativo',
|
||||
'muy_negativo',
|
||||
'giro_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'muy_positivo' => 'decimal:4',
|
||||
'positivo' => 'decimal:4',
|
||||
'neutral' => 'decimal:4',
|
||||
'negativo' => 'decimal:4',
|
||||
'muy_negativo' => 'decimal:4',
|
||||
];
|
||||
}
|
||||
|
||||
public function giro(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Giro::class);
|
||||
}
|
||||
|
||||
public function evaluarValor(float $valor): string
|
||||
{
|
||||
if ($this->muy_positivo !== null && $valor >= $this->muy_positivo) {
|
||||
return 'muy_positivo';
|
||||
}
|
||||
if ($this->positivo !== null && $valor >= $this->positivo) {
|
||||
return 'positivo';
|
||||
}
|
||||
if ($this->neutral !== null && $valor >= $this->neutral) {
|
||||
return 'neutral';
|
||||
}
|
||||
if ($this->negativo !== null && $valor >= $this->negativo) {
|
||||
return 'negativo';
|
||||
}
|
||||
return 'muy_negativo';
|
||||
}
|
||||
}
|
||||
75
backend/app/Models/User.php
Normal file
75
backend/app/Models/User.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'nombre',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'cliente_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function permisosEmpleado(): HasMany
|
||||
{
|
||||
return $this->hasMany(PermisoEmpleado::class);
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function isAnalista(): bool
|
||||
{
|
||||
return $this->role === 'analista';
|
||||
}
|
||||
|
||||
public function isCliente(): bool
|
||||
{
|
||||
return $this->role === 'cliente';
|
||||
}
|
||||
|
||||
public function isEmpleado(): bool
|
||||
{
|
||||
return $this->role === 'empleado';
|
||||
}
|
||||
|
||||
public function canAccessCliente(int $clienteId): bool
|
||||
{
|
||||
if ($this->isAdmin() || $this->isAnalista()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->cliente_id === $clienteId;
|
||||
}
|
||||
}
|
||||
419
backend/app/Services/CalculadorMetricas.php
Normal file
419
backend/app/Services/CalculadorMetricas.php
Normal file
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Reporte;
|
||||
use App\Models\Umbral;
|
||||
use App\Models\CategoriaContable;
|
||||
|
||||
class CalculadorMetricas
|
||||
{
|
||||
private array $categorias;
|
||||
private ?int $giroId;
|
||||
|
||||
public function calcular(Reporte $reporte): array
|
||||
{
|
||||
$this->giroId = $reporte->cliente->giro_id;
|
||||
$this->cargarCategorias();
|
||||
|
||||
$balanzas = $reporte->balanzas()->with('cuentas')->orderBy('periodo_fin')->get();
|
||||
|
||||
// Calcular estados financieros para cada periodo
|
||||
$periodos = [];
|
||||
foreach ($balanzas as $balanza) {
|
||||
$periodos[] = [
|
||||
'periodo' => $balanza->periodo_fin->format('Y-m'),
|
||||
'balance_general' => $this->calcularBalanceGeneral($balanza),
|
||||
'estado_resultados' => $this->calcularEstadoResultados($balanza),
|
||||
];
|
||||
}
|
||||
|
||||
// Calcular métricas del último periodo
|
||||
$ultimoPeriodo = end($periodos);
|
||||
$metricas = $this->calcularTodasLasMetricas($ultimoPeriodo);
|
||||
|
||||
// Calcular comparativos si hay múltiples periodos
|
||||
$comparativos = [];
|
||||
if (count($periodos) >= 2) {
|
||||
$comparativos = $this->calcularComparativos($periodos, $metricas);
|
||||
}
|
||||
|
||||
// Calcular flujo de efectivo
|
||||
$flujoEfectivo = $this->calcularFlujoEfectivo($periodos);
|
||||
|
||||
return [
|
||||
'periodos' => $periodos,
|
||||
'metricas' => $metricas,
|
||||
'comparativos' => $comparativos,
|
||||
'flujo_efectivo' => $flujoEfectivo,
|
||||
'estados_financieros' => [
|
||||
'balance_general' => $ultimoPeriodo['balance_general'],
|
||||
'estado_resultados' => $ultimoPeriodo['estado_resultados'],
|
||||
'flujo_efectivo' => $flujoEfectivo,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function cargarCategorias(): void
|
||||
{
|
||||
$this->categorias = CategoriaContable::all()->keyBy('nombre')->toArray();
|
||||
}
|
||||
|
||||
private function calcularBalanceGeneral($balanza): array
|
||||
{
|
||||
$cuentas = $balanza->cuentasActivas()
|
||||
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Balance General'))
|
||||
->get();
|
||||
|
||||
$totales = [
|
||||
'activos_circulantes' => 0,
|
||||
'activos_no_circulantes' => 0,
|
||||
'total_activos' => 0,
|
||||
'pasivo_circulante' => 0,
|
||||
'pasivo_no_circulante' => 0,
|
||||
'total_pasivos' => 0,
|
||||
'capital_social' => 0,
|
||||
'utilidades_anteriores' => 0,
|
||||
'perdidas_anteriores' => 0,
|
||||
'total_capital' => 0,
|
||||
];
|
||||
|
||||
foreach ($cuentas as $cuenta) {
|
||||
if (!$cuenta->categoriaContable) continue;
|
||||
|
||||
$saldo = $cuenta->saldo_final_neto;
|
||||
$categoria = $cuenta->categoriaContable->nombre;
|
||||
|
||||
switch ($categoria) {
|
||||
case 'Activos Circulantes':
|
||||
$totales['activos_circulantes'] += $saldo;
|
||||
break;
|
||||
case 'Activos No Circulantes':
|
||||
$totales['activos_no_circulantes'] += $saldo;
|
||||
break;
|
||||
case 'Pasivo Circulante':
|
||||
$totales['pasivo_circulante'] += abs($saldo);
|
||||
break;
|
||||
case 'Pasivo No Circulante':
|
||||
$totales['pasivo_no_circulante'] += abs($saldo);
|
||||
break;
|
||||
case 'Capital Social':
|
||||
$totales['capital_social'] += abs($saldo);
|
||||
break;
|
||||
case 'Utilidades Ejercicios Anteriores':
|
||||
$totales['utilidades_anteriores'] += abs($saldo);
|
||||
break;
|
||||
case 'Pérdidas Ejercicios Anteriores':
|
||||
$totales['perdidas_anteriores'] += abs($saldo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$totales['total_activos'] = $totales['activos_circulantes'] + $totales['activos_no_circulantes'];
|
||||
$totales['total_pasivos'] = $totales['pasivo_circulante'] + $totales['pasivo_no_circulante'];
|
||||
$totales['total_capital'] = $totales['capital_social'] + $totales['utilidades_anteriores'] - $totales['perdidas_anteriores'];
|
||||
|
||||
return $totales;
|
||||
}
|
||||
|
||||
private function calcularEstadoResultados($balanza): array
|
||||
{
|
||||
$cuentas = $balanza->cuentasActivas()
|
||||
->whereHas('reporteContable', fn($q) => $q->where('nombre', 'Estado de Resultados'))
|
||||
->get();
|
||||
|
||||
$totales = [
|
||||
'ingresos' => 0,
|
||||
'costo_venta' => 0,
|
||||
'utilidad_bruta' => 0,
|
||||
'gastos_operativos' => 0,
|
||||
'utilidad_operativa' => 0,
|
||||
'otros_gastos' => 0,
|
||||
'gastos_financieros' => 0,
|
||||
'utilidad_antes_impuestos' => 0,
|
||||
'impuestos' => 0,
|
||||
'utilidad_neta' => 0,
|
||||
];
|
||||
|
||||
foreach ($cuentas as $cuenta) {
|
||||
if (!$cuenta->categoriaContable) continue;
|
||||
|
||||
$saldo = abs($cuenta->saldo_final_neto);
|
||||
$categoria = $cuenta->categoriaContable->nombre;
|
||||
|
||||
switch ($categoria) {
|
||||
case 'Ingresos':
|
||||
$totales['ingresos'] += $saldo;
|
||||
break;
|
||||
case 'Costo de Venta':
|
||||
$totales['costo_venta'] += $saldo;
|
||||
break;
|
||||
case 'Gastos Operativos':
|
||||
$totales['gastos_operativos'] += $saldo;
|
||||
break;
|
||||
case 'Otros Gastos':
|
||||
$totales['otros_gastos'] += $saldo;
|
||||
break;
|
||||
case 'Gastos Financieros':
|
||||
$totales['gastos_financieros'] += $saldo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calcular subtotales
|
||||
$totales['utilidad_bruta'] = $totales['ingresos'] - $totales['costo_venta'];
|
||||
$totales['utilidad_operativa'] = $totales['utilidad_bruta'] - $totales['gastos_operativos'];
|
||||
$totales['utilidad_antes_impuestos'] = $totales['utilidad_operativa'] - $totales['otros_gastos'] - $totales['gastos_financieros'];
|
||||
|
||||
// Estimación de impuestos (30% para México)
|
||||
$totales['impuestos'] = max(0, $totales['utilidad_antes_impuestos'] * 0.30);
|
||||
$totales['utilidad_neta'] = $totales['utilidad_antes_impuestos'] - $totales['impuestos'];
|
||||
|
||||
return $totales;
|
||||
}
|
||||
|
||||
private function calcularTodasLasMetricas(array $periodo): array
|
||||
{
|
||||
$balance = $periodo['balance_general'];
|
||||
$resultados = $periodo['estado_resultados'];
|
||||
|
||||
$metricas = [];
|
||||
|
||||
// === MÁRGENES ===
|
||||
$ingresos = $resultados['ingresos'] ?: 1; // Evitar división por cero
|
||||
|
||||
$metricas['margen_bruto'] = $this->crearMetrica(
|
||||
'Margen Bruto',
|
||||
$resultados['utilidad_bruta'] / $ingresos,
|
||||
'margen_bruto'
|
||||
);
|
||||
|
||||
// EBITDA = Utilidad Operativa + Depreciación (estimada como 5% de activos fijos)
|
||||
$depreciacion = $balance['activos_no_circulantes'] * 0.05;
|
||||
$ebitda = $resultados['utilidad_operativa'] + $depreciacion;
|
||||
|
||||
$metricas['margen_ebitda'] = $this->crearMetrica(
|
||||
'Margen EBITDA',
|
||||
$ebitda / $ingresos,
|
||||
'margen_ebitda'
|
||||
);
|
||||
|
||||
$metricas['margen_operativo'] = $this->crearMetrica(
|
||||
'Margen Operativo',
|
||||
$resultados['utilidad_operativa'] / $ingresos,
|
||||
'margen_operativo'
|
||||
);
|
||||
|
||||
$metricas['margen_neto'] = $this->crearMetrica(
|
||||
'Margen Neto',
|
||||
$resultados['utilidad_neta'] / $ingresos,
|
||||
'margen_neto'
|
||||
);
|
||||
|
||||
// NOPAT = EBIT * (1 - Tasa impuestos)
|
||||
$nopat = $resultados['utilidad_operativa'] * 0.70;
|
||||
$metricas['margen_nopat'] = $this->crearMetrica(
|
||||
'Margen NOPAT',
|
||||
$nopat / $ingresos,
|
||||
'margen_nopat'
|
||||
);
|
||||
|
||||
// === RETORNO ===
|
||||
$capitalInvertido = $balance['total_activos'] - $balance['pasivo_circulante'];
|
||||
$capitalEmpleado = $balance['total_activos'] - $balance['pasivo_circulante'];
|
||||
|
||||
$metricas['roic'] = $this->crearMetrica(
|
||||
'ROIC',
|
||||
$capitalInvertido > 0 ? $nopat / $capitalInvertido : 0,
|
||||
'roic'
|
||||
);
|
||||
|
||||
$metricas['roe'] = $this->crearMetrica(
|
||||
'ROE',
|
||||
$balance['total_capital'] > 0 ? $resultados['utilidad_neta'] / $balance['total_capital'] : 0,
|
||||
'roe'
|
||||
);
|
||||
|
||||
$metricas['roa'] = $this->crearMetrica(
|
||||
'ROA',
|
||||
$balance['total_activos'] > 0 ? $resultados['utilidad_neta'] / $balance['total_activos'] : 0,
|
||||
'roa'
|
||||
);
|
||||
|
||||
$metricas['roce'] = $this->crearMetrica(
|
||||
'ROCE',
|
||||
$capitalEmpleado > 0 ? $resultados['utilidad_operativa'] / $capitalEmpleado : 0,
|
||||
'roce'
|
||||
);
|
||||
|
||||
// === LIQUIDEZ ===
|
||||
$pasivoCirculante = $balance['pasivo_circulante'] ?: 1;
|
||||
|
||||
$metricas['current_ratio'] = $this->crearMetrica(
|
||||
'Current Ratio',
|
||||
$balance['activos_circulantes'] / $pasivoCirculante,
|
||||
'current_ratio'
|
||||
);
|
||||
|
||||
// Quick ratio (estimando inventario como 30% de activos circulantes)
|
||||
$inventarioEstimado = $balance['activos_circulantes'] * 0.30;
|
||||
$metricas['quick_ratio'] = $this->crearMetrica(
|
||||
'Quick Ratio',
|
||||
($balance['activos_circulantes'] - $inventarioEstimado) / $pasivoCirculante,
|
||||
'quick_ratio'
|
||||
);
|
||||
|
||||
// Cash ratio (estimando efectivo como 15% de activos circulantes)
|
||||
$efectivoEstimado = $balance['activos_circulantes'] * 0.15;
|
||||
$metricas['cash_ratio'] = $this->crearMetrica(
|
||||
'Cash Ratio',
|
||||
$efectivoEstimado / $pasivoCirculante,
|
||||
'cash_ratio'
|
||||
);
|
||||
|
||||
// === SOLVENCIA ===
|
||||
$deudaTotal = $balance['total_pasivos'];
|
||||
$ebitdaAnual = $ebitda * 12; // Anualizar si es mensual
|
||||
|
||||
$metricas['net_debt_ebitda'] = $this->crearMetrica(
|
||||
'Net Debt / EBITDA',
|
||||
$ebitdaAnual > 0 ? ($deudaTotal - $efectivoEstimado) / $ebitdaAnual : 0,
|
||||
'net_debt_ebitda'
|
||||
);
|
||||
|
||||
$gastosFinancieros = $resultados['gastos_financieros'] ?: 1;
|
||||
$metricas['interest_coverage'] = $this->crearMetrica(
|
||||
'Interest Coverage',
|
||||
$ebitda / $gastosFinancieros,
|
||||
'interest_coverage'
|
||||
);
|
||||
|
||||
$metricas['debt_ratio'] = $this->crearMetrica(
|
||||
'Debt Ratio',
|
||||
$balance['total_activos'] > 0 ? $deudaTotal / $balance['total_activos'] : 0,
|
||||
'debt_ratio'
|
||||
);
|
||||
|
||||
return $metricas;
|
||||
}
|
||||
|
||||
private function crearMetrica(string $nombre, float $valor, string $codigoUmbral): array
|
||||
{
|
||||
$umbral = $this->obtenerUmbral($codigoUmbral);
|
||||
$tendencia = $umbral ? $umbral->evaluarValor($valor) : 'neutral';
|
||||
|
||||
return [
|
||||
'nombre' => $nombre,
|
||||
'valor' => round($valor, 4),
|
||||
'valor_porcentaje' => round($valor * 100, 2),
|
||||
'tendencia' => $tendencia,
|
||||
];
|
||||
}
|
||||
|
||||
private function obtenerUmbral(string $metrica): ?Umbral
|
||||
{
|
||||
// Buscar umbral específico del giro primero
|
||||
$umbral = Umbral::where('metrica', $metrica)
|
||||
->where('giro_id', $this->giroId)
|
||||
->first();
|
||||
|
||||
if (!$umbral) {
|
||||
// Buscar umbral genérico
|
||||
$umbral = Umbral::where('metrica', $metrica)
|
||||
->whereNull('giro_id')
|
||||
->first();
|
||||
}
|
||||
|
||||
return $umbral;
|
||||
}
|
||||
|
||||
private function calcularComparativos(array $periodos, array $metricasActuales): array
|
||||
{
|
||||
$comparativos = [];
|
||||
|
||||
if (count($periodos) < 2) {
|
||||
return $comparativos;
|
||||
}
|
||||
|
||||
$periodoActual = end($periodos);
|
||||
$periodoAnterior = $periodos[count($periodos) - 2];
|
||||
|
||||
// Calcular métricas del periodo anterior
|
||||
$metricasAnterior = $this->calcularTodasLasMetricas($periodoAnterior);
|
||||
|
||||
foreach ($metricasActuales as $key => $metrica) {
|
||||
if (isset($metricasAnterior[$key])) {
|
||||
$valorActual = $metrica['valor'];
|
||||
$valorAnterior = $metricasAnterior[$key]['valor'];
|
||||
$variacion = $valorAnterior != 0 ? ($valorActual - $valorAnterior) / abs($valorAnterior) : 0;
|
||||
|
||||
$comparativos[$key] = [
|
||||
'valor_actual' => $valorActual,
|
||||
'valor_anterior' => $valorAnterior,
|
||||
'variacion_absoluta' => $valorActual - $valorAnterior,
|
||||
'variacion_porcentual' => round($variacion * 100, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Promedio de 3 periodos si hay suficientes datos
|
||||
if (count($periodos) >= 3) {
|
||||
$ultimos3 = array_slice($periodos, -3);
|
||||
foreach ($metricasActuales as $key => $metrica) {
|
||||
$suma = 0;
|
||||
foreach ($ultimos3 as $p) {
|
||||
$m = $this->calcularTodasLasMetricas($p);
|
||||
$suma += $m[$key]['valor'] ?? 0;
|
||||
}
|
||||
$comparativos[$key]['promedio_3_periodos'] = round($suma / 3, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return $comparativos;
|
||||
}
|
||||
|
||||
private function calcularFlujoEfectivo(array $periodos): array
|
||||
{
|
||||
if (count($periodos) < 2) {
|
||||
return ['metodo' => 'indirecto', 'sin_datos' => true];
|
||||
}
|
||||
|
||||
$actual = end($periodos);
|
||||
$anterior = $periodos[count($periodos) - 2];
|
||||
|
||||
$balanceActual = $actual['balance_general'];
|
||||
$balanceAnterior = $anterior['balance_general'];
|
||||
$resultados = $actual['estado_resultados'];
|
||||
|
||||
// Método indirecto
|
||||
$utilidadNeta = $resultados['utilidad_neta'];
|
||||
$depreciacion = $balanceActual['activos_no_circulantes'] * 0.05;
|
||||
|
||||
// Cambios en capital de trabajo
|
||||
$cambioActivosCirc = $balanceActual['activos_circulantes'] - $balanceAnterior['activos_circulantes'];
|
||||
$cambioPasivosCirc = $balanceActual['pasivo_circulante'] - $balanceAnterior['pasivo_circulante'];
|
||||
|
||||
$flujoOperacion = $utilidadNeta + $depreciacion - $cambioActivosCirc + $cambioPasivosCirc;
|
||||
|
||||
// Flujo de inversión (cambio en activos no circulantes)
|
||||
$flujoInversion = -($balanceActual['activos_no_circulantes'] - $balanceAnterior['activos_no_circulantes']);
|
||||
|
||||
// Flujo de financiamiento
|
||||
$cambioDeuda = $balanceActual['total_pasivos'] - $balanceAnterior['total_pasivos'];
|
||||
$cambioCapital = $balanceActual['total_capital'] - $balanceAnterior['total_capital'];
|
||||
$flujoFinanciamiento = $cambioDeuda + $cambioCapital - $utilidadNeta;
|
||||
|
||||
return [
|
||||
'metodo' => 'indirecto',
|
||||
'flujo_operacion' => round($flujoOperacion, 2),
|
||||
'flujo_inversion' => round($flujoInversion, 2),
|
||||
'flujo_financiamiento' => round($flujoFinanciamiento, 2),
|
||||
'flujo_neto' => round($flujoOperacion + $flujoInversion + $flujoFinanciamiento, 2),
|
||||
'detalle' => [
|
||||
'utilidad_neta' => $utilidadNeta,
|
||||
'depreciacion' => $depreciacion,
|
||||
'cambio_capital_trabajo' => -$cambioActivosCirc + $cambioPasivosCirc,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
119
backend/app/Services/ClasificadorCuentas.php
Normal file
119
backend/app/Services/ClasificadorCuentas.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Balanza;
|
||||
use App\Models\Cuenta;
|
||||
use App\Models\ReglaMapeo;
|
||||
use App\Models\MapeoCuenta;
|
||||
|
||||
class ClasificadorCuentas
|
||||
{
|
||||
/**
|
||||
* Clasifica todas las cuentas de una balanza según las reglas de mapeo
|
||||
*/
|
||||
public function clasificar(Balanza $balanza): void
|
||||
{
|
||||
$cuentas = $balanza->cuentas()->get();
|
||||
$sistemaOrigen = $balanza->sistema_origen;
|
||||
$clienteId = $balanza->cliente_id;
|
||||
|
||||
// Obtener reglas ordenadas por prioridad
|
||||
$reglas = ReglaMapeo::where('sistema_origen', $sistemaOrigen)
|
||||
->where('activo', true)
|
||||
->orderByDesc('prioridad')
|
||||
->get();
|
||||
|
||||
// Obtener mapeos específicos del cliente
|
||||
$mapeosCliente = MapeoCuenta::where('cliente_id', $clienteId)->get();
|
||||
|
||||
foreach ($cuentas as $cuenta) {
|
||||
$this->clasificarCuenta($cuenta, $reglas, $mapeosCliente);
|
||||
}
|
||||
|
||||
// Establecer relaciones padre-hijo en base de datos
|
||||
$this->establecerRelacionesPadreHijo($balanza);
|
||||
}
|
||||
|
||||
private function clasificarCuenta(Cuenta $cuenta, $reglas, $mapeosCliente): void
|
||||
{
|
||||
// Primero buscar en mapeos específicos del cliente
|
||||
foreach ($mapeosCliente as $mapeo) {
|
||||
if ($this->coincidePatron($cuenta->codigo, $mapeo->codigo_patron)) {
|
||||
$cuenta->update([
|
||||
'categoria_contable_id' => $mapeo->categoria_contable_id,
|
||||
'reporte_contable_id' => $mapeo->categoriaContable->reporte_contable_id,
|
||||
'requiere_revision' => false,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar en reglas del sistema
|
||||
foreach ($reglas as $regla) {
|
||||
if ($regla->coincideCon($cuenta->codigo)) {
|
||||
$cuenta->update([
|
||||
'categoria_contable_id' => $regla->categoria_contable_id,
|
||||
'reporte_contable_id' => $regla->reporte_contable_id,
|
||||
'requiere_revision' => false,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No se encontró regla - marcar para revisión
|
||||
$this->marcarParaRevision($cuenta);
|
||||
}
|
||||
|
||||
private function coincidePatron(string $codigo, string $patron): bool
|
||||
{
|
||||
// El patrón puede ser exacto o con wildcards (*)
|
||||
$regex = '/^' . str_replace(['*', '-'], ['.*', '\-'], $patron) . '$/';
|
||||
return (bool) preg_match($regex, $codigo);
|
||||
}
|
||||
|
||||
private function marcarParaRevision(Cuenta $cuenta): void
|
||||
{
|
||||
$nota = $this->generarNotaRevision($cuenta);
|
||||
|
||||
$cuenta->update([
|
||||
'requiere_revision' => true,
|
||||
'nota_revision' => $nota,
|
||||
]);
|
||||
}
|
||||
|
||||
private function generarNotaRevision(Cuenta $cuenta): string
|
||||
{
|
||||
$codigo = $cuenta->codigo;
|
||||
$notas = [];
|
||||
|
||||
// Detectar posibles anomalías basadas en el código
|
||||
if (preg_match('/^45[0-9]-/', $codigo)) {
|
||||
$notas[] = 'Código 45X normalmente es pasivo pero podría ser gasto. Verificar clasificación.';
|
||||
}
|
||||
|
||||
if (preg_match('/^[89][0-9]{2}-/', $codigo)) {
|
||||
$notas[] = 'Cuenta de orden o especial. Verificar si debe incluirse en estados financieros.';
|
||||
}
|
||||
|
||||
if (empty($notas)) {
|
||||
$notas[] = 'No se encontró regla de mapeo para este código. Asignar clasificación manualmente.';
|
||||
}
|
||||
|
||||
return implode(' ', $notas);
|
||||
}
|
||||
|
||||
private function establecerRelacionesPadreHijo(Balanza $balanza): void
|
||||
{
|
||||
$cuentas = $balanza->cuentas()->get()->keyBy('codigo');
|
||||
|
||||
foreach ($cuentas as $cuenta) {
|
||||
if (isset($cuenta->cuenta_padre_codigo)) {
|
||||
$padre = $cuentas->get($cuenta->cuenta_padre_codigo);
|
||||
if ($padre) {
|
||||
$cuenta->update(['cuenta_padre_id' => $padre->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
backend/app/Services/GeneradorPdf.php
Normal file
60
backend/app/Services/GeneradorPdf.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Reporte;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
|
||||
class GeneradorPdf
|
||||
{
|
||||
private string $frontendUrl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->frontendUrl = config('app.frontend_url', 'http://localhost:5173');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el PDF del reporte
|
||||
*
|
||||
* @param Reporte $reporte
|
||||
* @return string Path relativo del PDF generado
|
||||
*/
|
||||
public function generar(Reporte $reporte): string
|
||||
{
|
||||
$outputPath = 'reportes/' . $reporte->cliente_id . '/' . $reporte->id . '.pdf';
|
||||
$fullPath = storage_path('app/' . $outputPath);
|
||||
|
||||
// Crear directorio si no existe
|
||||
$dir = dirname($fullPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
// URL del frontend para renderizar el reporte
|
||||
$url = $this->frontendUrl . '/pdf-view/' . $reporte->id . '?token=' . $this->generarTokenTemporal($reporte);
|
||||
|
||||
Browsershot::url($url)
|
||||
->waitUntilNetworkIdle()
|
||||
->format('Letter')
|
||||
->margins(0, 0, 0, 0)
|
||||
->showBackground()
|
||||
->save($fullPath);
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token temporal para acceso al PDF
|
||||
*/
|
||||
private function generarTokenTemporal(Reporte $reporte): string
|
||||
{
|
||||
// Token válido por 5 minutos
|
||||
$data = [
|
||||
'reporte_id' => $reporte->id,
|
||||
'expires' => time() + 300,
|
||||
];
|
||||
|
||||
return base64_encode(json_encode($data));
|
||||
}
|
||||
}
|
||||
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal file
149
backend/app/Services/Parsers/ContpaqiParser.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
use Spatie\PdfToText\Pdf;
|
||||
|
||||
class ContpaqiParser implements ParserInterface
|
||||
{
|
||||
public function getSistema(): string
|
||||
{
|
||||
return 'contpaqi';
|
||||
}
|
||||
|
||||
public function puedeManej(string $filePath): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension !== 'pdf') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$text = Pdf::getText($filePath);
|
||||
|
||||
// Buscar patrones característicos de CONTPAQi
|
||||
$patronesContpaqi = [
|
||||
'/CONTPAQ/i',
|
||||
'/Balanza de Comprobaci[óo]n/i',
|
||||
'/\d{3}-\d{3}-\d{3}/', // Patrón de código de cuenta CONTPAQi
|
||||
'/Saldo\s+Inicial.*Debe.*Haber.*Saldo\s+Final/is',
|
||||
];
|
||||
|
||||
$coincidencias = 0;
|
||||
foreach ($patronesContpaqi as $patron) {
|
||||
if (preg_match($patron, $text)) {
|
||||
$coincidencias++;
|
||||
}
|
||||
}
|
||||
|
||||
return $coincidencias >= 2;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function parsear(string $filePath): array
|
||||
{
|
||||
$text = Pdf::getText($filePath);
|
||||
$lineas = explode("\n", $text);
|
||||
$cuentas = [];
|
||||
|
||||
foreach ($lineas as $linea) {
|
||||
$cuenta = $this->parsearLinea($linea);
|
||||
if ($cuenta) {
|
||||
$cuentas[] = $cuenta;
|
||||
}
|
||||
}
|
||||
|
||||
// Establecer relaciones padre-hijo
|
||||
$this->establecerJerarquia($cuentas);
|
||||
|
||||
return $cuentas;
|
||||
}
|
||||
|
||||
private function parsearLinea(string $linea): ?array
|
||||
{
|
||||
// Patrón para líneas de cuenta CONTPAQi
|
||||
// Formato típico: "001-100-000 ACTIVO CIRCULANTE 1,234.56 0.00 500.00 200.00 1,534.56 0.00"
|
||||
$patron = '/^(\d{3}-\d{3}-\d{3})\s+(.+?)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)\s+([\d,]+\.?\d*)$/';
|
||||
|
||||
if (!preg_match($patron, trim($linea), $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$codigo = $matches[1];
|
||||
$nombre = trim($matches[2]);
|
||||
|
||||
// Determinar nivel basado en el código
|
||||
$nivel = $this->determinarNivel($codigo);
|
||||
|
||||
// Determinar si es cuenta padre (termina en -000-000 o -XXX-000)
|
||||
$esCuentaPadre = preg_match('/-000-000$/', $codigo) || preg_match('/-\d{3}-000$/', $codigo);
|
||||
|
||||
return [
|
||||
'codigo' => $codigo,
|
||||
'nombre' => $nombre,
|
||||
'nivel' => $nivel,
|
||||
'saldo_inicial_deudor' => $this->parsearNumero($matches[3]),
|
||||
'saldo_inicial_acreedor' => $this->parsearNumero($matches[4]),
|
||||
'cargos' => $this->parsearNumero($matches[5]),
|
||||
'abonos' => $this->parsearNumero($matches[6]),
|
||||
'saldo_final_deudor' => $this->parsearNumero($matches[7]),
|
||||
'saldo_final_acreedor' => $this->parsearNumero($matches[8]),
|
||||
'es_cuenta_padre' => $esCuentaPadre,
|
||||
];
|
||||
}
|
||||
|
||||
private function determinarNivel(string $codigo): int
|
||||
{
|
||||
// En CONTPAQi el nivel se puede inferir del patrón de código
|
||||
// XXX-000-000 = Nivel 1 (cuenta mayor)
|
||||
// XXX-XXX-000 = Nivel 2 (subcuenta)
|
||||
// XXX-XXX-XXX = Nivel 3 (detalle)
|
||||
|
||||
if (preg_match('/-000-000$/', $codigo)) {
|
||||
return 1;
|
||||
}
|
||||
if (preg_match('/-\d{3}-000$/', $codigo)) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private function parsearNumero(string $valor): float
|
||||
{
|
||||
// Remover comas y convertir a float
|
||||
return (float) str_replace(',', '', $valor);
|
||||
}
|
||||
|
||||
private function establecerJerarquia(array &$cuentas): void
|
||||
{
|
||||
// Crear índice por código
|
||||
$indice = [];
|
||||
foreach ($cuentas as $i => $cuenta) {
|
||||
$indice[$cuenta['codigo']] = $i;
|
||||
}
|
||||
|
||||
// Establecer padres basado en el código
|
||||
foreach ($cuentas as $i => &$cuenta) {
|
||||
$codigo = $cuenta['codigo'];
|
||||
$partes = explode('-', $codigo);
|
||||
|
||||
// Buscar cuenta padre
|
||||
if ($partes[2] !== '000') {
|
||||
// Buscar padre de nivel 2 (XXX-XXX-000)
|
||||
$codigoPadre = $partes[0] . '-' . $partes[1] . '-000';
|
||||
if (isset($indice[$codigoPadre])) {
|
||||
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
|
||||
}
|
||||
} elseif ($partes[1] !== '000') {
|
||||
// Buscar padre de nivel 1 (XXX-000-000)
|
||||
$codigoPadre = $partes[0] . '-000-000';
|
||||
if (isset($indice[$codigoPadre])) {
|
||||
$cuenta['cuenta_padre_codigo'] = $codigoPadre;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
backend/app/Services/Parsers/DetectorFormato.php
Normal file
47
backend/app/Services/Parsers/DetectorFormato.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
class DetectorFormato
|
||||
{
|
||||
private array $parsers;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->parsers = [
|
||||
new ContpaqiParser(),
|
||||
// Agregar más parsers aquí: AspelParser, SapParser, etc.
|
||||
new GenericoParser(), // Debe ser el último como fallback
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta el formato del archivo y retorna el parser apropiado
|
||||
*
|
||||
* @param string $filePath Ruta completa al archivo
|
||||
* @return array{sistema: string, parser: ParserInterface}
|
||||
* @throws \Exception Si no se puede detectar el formato
|
||||
*/
|
||||
public function detectar(string $filePath): array
|
||||
{
|
||||
foreach ($this->parsers as $parser) {
|
||||
if ($parser->puedeManej($filePath)) {
|
||||
return [
|
||||
'sistema' => $parser->getSistema(),
|
||||
'parser' => $parser,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception('No se pudo detectar el formato del archivo');
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra un parser adicional
|
||||
*/
|
||||
public function registrarParser(ParserInterface $parser): void
|
||||
{
|
||||
// Insertar antes del GenericoParser
|
||||
array_splice($this->parsers, -1, 0, [$parser]);
|
||||
}
|
||||
}
|
||||
180
backend/app/Services/Parsers/GenericoParser.php
Normal file
180
backend/app/Services/Parsers/GenericoParser.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class GenericoParser implements ParserInterface
|
||||
{
|
||||
private array $mapeoColumnas = [
|
||||
'codigo' => ['codigo', 'cuenta', 'code', 'account', 'cta', 'numero'],
|
||||
'nombre' => ['nombre', 'descripcion', 'name', 'description', 'concepto'],
|
||||
'saldo_inicial_deudor' => ['saldo_inicial_deudor', 'inicial_debe', 'opening_debit', 'si_deudor'],
|
||||
'saldo_inicial_acreedor' => ['saldo_inicial_acreedor', 'inicial_haber', 'opening_credit', 'si_acreedor'],
|
||||
'cargos' => ['cargos', 'debe', 'debit', 'debits', 'movs_deudor'],
|
||||
'abonos' => ['abonos', 'haber', 'credit', 'credits', 'movs_acreedor'],
|
||||
'saldo_final_deudor' => ['saldo_final_deudor', 'final_debe', 'closing_debit', 'sf_deudor'],
|
||||
'saldo_final_acreedor' => ['saldo_final_acreedor', 'final_haber', 'closing_credit', 'sf_acreedor'],
|
||||
];
|
||||
|
||||
public function getSistema(): string
|
||||
{
|
||||
return 'generico';
|
||||
}
|
||||
|
||||
public function puedeManej(string $filePath): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
return in_array($extension, ['xlsx', 'xls', 'csv']);
|
||||
}
|
||||
|
||||
public function parsear(string $filePath): array
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
|
||||
if ($extension === 'csv') {
|
||||
return $this->parsearCsv($filePath);
|
||||
}
|
||||
|
||||
return $this->parsearExcel($filePath);
|
||||
}
|
||||
|
||||
private function parsearExcel(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $worksheet->toArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Primera fila son headers
|
||||
$headers = array_map(fn($h) => $this->normalizarHeader($h), $rows[0]);
|
||||
$mapeo = $this->mapearColumnas($headers);
|
||||
|
||||
$cuentas = [];
|
||||
for ($i = 1; $i < count($rows); $i++) {
|
||||
$cuenta = $this->parsearFila($rows[$i], $mapeo);
|
||||
if ($cuenta) {
|
||||
$cuentas[] = $cuenta;
|
||||
}
|
||||
}
|
||||
|
||||
return $cuentas;
|
||||
}
|
||||
|
||||
private function parsearCsv(string $filePath): array
|
||||
{
|
||||
$handle = fopen($filePath, 'r');
|
||||
if (!$handle) {
|
||||
throw new \Exception('No se pudo abrir el archivo CSV');
|
||||
}
|
||||
|
||||
// Detectar delimitador
|
||||
$primeraLinea = fgets($handle);
|
||||
rewind($handle);
|
||||
$delimitador = $this->detectarDelimitador($primeraLinea);
|
||||
|
||||
// Primera fila son headers
|
||||
$headers = fgetcsv($handle, 0, $delimitador);
|
||||
$headers = array_map(fn($h) => $this->normalizarHeader($h), $headers);
|
||||
$mapeo = $this->mapearColumnas($headers);
|
||||
|
||||
$cuentas = [];
|
||||
while (($row = fgetcsv($handle, 0, $delimitador)) !== false) {
|
||||
$cuenta = $this->parsearFila($row, $mapeo);
|
||||
if ($cuenta) {
|
||||
$cuentas[] = $cuenta;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return $cuentas;
|
||||
}
|
||||
|
||||
private function normalizarHeader(?string $header): string
|
||||
{
|
||||
if ($header === null) {
|
||||
return '';
|
||||
}
|
||||
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]/', '_', $header)));
|
||||
}
|
||||
|
||||
private function detectarDelimitador(string $linea): string
|
||||
{
|
||||
$delimitadores = [',', ';', "\t", '|'];
|
||||
$conteos = [];
|
||||
|
||||
foreach ($delimitadores as $d) {
|
||||
$conteos[$d] = substr_count($linea, $d);
|
||||
}
|
||||
|
||||
return array_keys($conteos, max($conteos))[0];
|
||||
}
|
||||
|
||||
private function mapearColumnas(array $headers): array
|
||||
{
|
||||
$mapeo = [];
|
||||
|
||||
foreach ($this->mapeoColumnas as $campo => $aliases) {
|
||||
foreach ($headers as $index => $header) {
|
||||
if (in_array($header, $aliases)) {
|
||||
$mapeo[$campo] = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapeo;
|
||||
}
|
||||
|
||||
private function parsearFila(array $row, array $mapeo): ?array
|
||||
{
|
||||
// Verificar que tenemos código y nombre
|
||||
if (!isset($mapeo['codigo']) || !isset($mapeo['nombre'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$codigo = trim($row[$mapeo['codigo']] ?? '');
|
||||
$nombre = trim($row[$mapeo['nombre']] ?? '');
|
||||
|
||||
if (empty($codigo) || empty($nombre)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determinar nivel basado en la estructura del código
|
||||
$nivel = $this->determinarNivel($codigo);
|
||||
$esCuentaPadre = $nivel <= 2;
|
||||
|
||||
return [
|
||||
'codigo' => $codigo,
|
||||
'nombre' => $nombre,
|
||||
'nivel' => $nivel,
|
||||
'saldo_inicial_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_deudor'),
|
||||
'saldo_inicial_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_inicial_acreedor'),
|
||||
'cargos' => $this->obtenerNumero($row, $mapeo, 'cargos'),
|
||||
'abonos' => $this->obtenerNumero($row, $mapeo, 'abonos'),
|
||||
'saldo_final_deudor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_deudor'),
|
||||
'saldo_final_acreedor' => $this->obtenerNumero($row, $mapeo, 'saldo_final_acreedor'),
|
||||
'es_cuenta_padre' => $esCuentaPadre,
|
||||
];
|
||||
}
|
||||
|
||||
private function determinarNivel(string $codigo): int
|
||||
{
|
||||
// Contar separadores para determinar nivel
|
||||
$separadores = preg_match_all('/[-.\s]/', $codigo);
|
||||
return min(3, $separadores + 1);
|
||||
}
|
||||
|
||||
private function obtenerNumero(array $row, array $mapeo, string $campo): float
|
||||
{
|
||||
if (!isset($mapeo[$campo])) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$valor = $row[$mapeo[$campo]] ?? 0;
|
||||
return (float) str_replace([',', '$', ' '], '', $valor);
|
||||
}
|
||||
}
|
||||
40
backend/app/Services/Parsers/ParserInterface.php
Normal file
40
backend/app/Services/Parsers/ParserInterface.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Parsers;
|
||||
|
||||
interface ParserInterface
|
||||
{
|
||||
/**
|
||||
* Parsea un archivo de balanza y retorna array de cuentas normalizadas
|
||||
*
|
||||
* @param string $filePath Ruta completa al archivo
|
||||
* @return array<int, array{
|
||||
* codigo: string,
|
||||
* nombre: string,
|
||||
* nivel: int,
|
||||
* saldo_inicial_deudor: float,
|
||||
* saldo_inicial_acreedor: float,
|
||||
* cargos: float,
|
||||
* abonos: float,
|
||||
* saldo_final_deudor: float,
|
||||
* saldo_final_acreedor: float,
|
||||
* es_cuenta_padre: bool
|
||||
* }>
|
||||
*/
|
||||
public function parsear(string $filePath): array;
|
||||
|
||||
/**
|
||||
* Verifica si este parser puede manejar el archivo
|
||||
*
|
||||
* @param string $filePath Ruta completa al archivo
|
||||
* @return bool
|
||||
*/
|
||||
public function puedeManej(string $filePath): bool;
|
||||
|
||||
/**
|
||||
* Retorna el identificador del sistema
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getSistema(): string;
|
||||
}
|
||||
Reference in New Issue
Block a user