313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* core/ModuleRegistry.php
|
|
* Catálogo dinámico de módulos del ERP.
|
|
*
|
|
* Fuentes de datos (prioridad en orden):
|
|
* 1. Base de datos — tabla system_modules (si la migración 004 se ejecutó)
|
|
* 2. Archivos modules/{slug}/module.php (escaneo de la carpeta modules/)
|
|
* 3. Constante SYSTEM_MODULES de config.php (fallback mínimo)
|
|
*
|
|
* Los datos de BD prevalecen sobre los descriptores locales para los campos
|
|
* is_active y sort_order (el administrador puede cambiarlos via UI).
|
|
*
|
|
* CACHÉ: estática por request (no Redis necesario).
|
|
*/
|
|
|
|
class ModuleRegistry
|
|
{
|
|
/** @var array<string, array>|null Cache del registro completo */
|
|
private static ?array $registry = null;
|
|
|
|
/** Orden de visualización de categorías en el sidebar */
|
|
private const CATEGORY_ORDER = ['bot', 'lab', 'turnero', 'clinico', 'reportes', 'sistema'];
|
|
|
|
/** Etiquetas e íconos de cada categoría */
|
|
private const CATEGORIES = [
|
|
'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'],
|
|
'lab' => ['label' => 'Laboratorio', 'icon' => 'fas fa-flask'],
|
|
'turnero' => ['label' => 'Turnero', 'icon' => 'fas fa-ticket-alt'],
|
|
'clinico' => ['label' => 'Clínico', 'icon' => 'fas fa-vials'],
|
|
'reportes'=> ['label' => 'Reportes', 'icon' => 'fas fa-chart-bar'],
|
|
'sistema' => ['label' => 'Sistema', 'icon' => 'fas fa-cog'],
|
|
];
|
|
|
|
// ─── API pública ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Retorna todos los módulos registrados (activos e inactivos).
|
|
*
|
|
* @return array<string, array> Indexado por slug.
|
|
*/
|
|
public static function getAll(): array
|
|
{
|
|
self::load();
|
|
return self::$registry;
|
|
}
|
|
|
|
/**
|
|
* Retorna solo los módulos ACTIVOS que el usuario actual puede ver.
|
|
* Si el usuario es admin sin role_id → todos los activos.
|
|
*
|
|
* @return array<string, array> Indexado por slug.
|
|
*/
|
|
public static function forCurrentUser(): array
|
|
{
|
|
self::load();
|
|
$result = [];
|
|
foreach (self::$registry as $slug => $mod) {
|
|
if (!$mod['is_active']) {
|
|
continue;
|
|
}
|
|
// hasModule() definida en config.php / core/Rbac.php (retrocompat)
|
|
if (function_exists('hasModule') && !hasModule($slug)) {
|
|
continue;
|
|
}
|
|
$result[$slug] = $mod;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Retorna módulos activos agrupados por categoría para el sidebar.
|
|
* Solo incluye módulos accesibles por el usuario actual.
|
|
*
|
|
* @return array ['bot' => ['label'=>'WhatsApp','icon'=>'...','modules'=>[...]], ...]
|
|
*/
|
|
public static function forSidebar(): array
|
|
{
|
|
$userModules = self::forCurrentUser();
|
|
|
|
// Agrupar por categoría
|
|
$grouped = [];
|
|
foreach ($userModules as $slug => $mod) {
|
|
$cat = $mod['category'] ?? 'sistema';
|
|
if (!isset($grouped[$cat])) {
|
|
$catMeta = self::CATEGORIES[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'fas fa-cube'];
|
|
$grouped[$cat] = [
|
|
'label' => $catMeta['label'],
|
|
'icon' => $catMeta['icon'],
|
|
'modules' => [],
|
|
];
|
|
}
|
|
$grouped[$cat]['modules'][$slug] = $mod;
|
|
}
|
|
|
|
// Ordenar grupos según CATEGORY_ORDER
|
|
$ordered = [];
|
|
foreach (self::CATEGORY_ORDER as $cat) {
|
|
if (isset($grouped[$cat])) {
|
|
// Ordenar módulos dentro de la categoría por sort_order
|
|
uasort($grouped[$cat]['modules'], fn($a, $b) => $a['sort_order'] <=> $b['sort_order']);
|
|
$ordered[$cat] = $grouped[$cat];
|
|
}
|
|
}
|
|
// Categorías extra no definidas en CATEGORY_ORDER al final
|
|
foreach ($grouped as $cat => $data) {
|
|
if (!isset($ordered[$cat])) {
|
|
$ordered[$cat] = $data;
|
|
}
|
|
}
|
|
|
|
return $ordered;
|
|
}
|
|
|
|
/**
|
|
* Retorna el descriptor de un módulo por slug, o null si no existe.
|
|
*/
|
|
public static function get(string $slug): ?array
|
|
{
|
|
self::load();
|
|
return self::$registry[$slug] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Activa o desactiva un módulo en la base de datos.
|
|
*
|
|
* @throws RuntimeException Si no se puede conectar a la BD.
|
|
*/
|
|
public static function setActive(string $slug, bool $active): bool
|
|
{
|
|
try {
|
|
$db = Database::getInstance();
|
|
$pdo = $db->getConnection();
|
|
$stmt = $pdo->prepare(
|
|
"UPDATE system_modules SET is_active = ? WHERE slug = ?"
|
|
);
|
|
$ok = $stmt->execute([(int) $active, $slug]);
|
|
if ($ok) {
|
|
self::$registry = null; // invalidar caché
|
|
}
|
|
return $ok;
|
|
} catch (Exception $e) {
|
|
error_log("ModuleRegistry::setActive error: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Actualiza el sort_order de un módulo.
|
|
*/
|
|
public static function setSortOrder(string $slug, int $order): bool
|
|
{
|
|
try {
|
|
$db = Database::getInstance();
|
|
$pdo = $db->getConnection();
|
|
$stmt = $pdo->prepare(
|
|
"UPDATE system_modules SET sort_order = ? WHERE slug = ?"
|
|
);
|
|
$ok = $stmt->execute([$order, $slug]);
|
|
if ($ok) {
|
|
self::$registry = null;
|
|
}
|
|
return $ok;
|
|
} catch (Exception $e) {
|
|
error_log("ModuleRegistry::setSortOrder error: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalida la caché para forzar recarga en la próxima llamada.
|
|
* Útil después de cambios por el administrador.
|
|
*/
|
|
public static function flush(): void
|
|
{
|
|
self::$registry = null;
|
|
}
|
|
|
|
// ─── Carga interna ──────────────────────────────────────────────────────
|
|
|
|
private static function load(): void
|
|
{
|
|
if (self::$registry !== null) {
|
|
return;
|
|
}
|
|
|
|
self::$registry = [];
|
|
|
|
// 1. Escanear module.php locales para obtener metadatos base
|
|
self::loadFromFiles();
|
|
|
|
// 2. Sobreescribir is_active / sort_order desde la BD (si existe)
|
|
self::mergeFromDatabase();
|
|
|
|
// 3. Fallback: añadir slugs de SYSTEM_MODULES que no tengan module.php
|
|
self::fillFromConstant();
|
|
}
|
|
|
|
/**
|
|
* Escanea modules/{slug}/module.php y carga sus descriptores.
|
|
*/
|
|
private static function loadFromFiles(): void
|
|
{
|
|
if (!defined('APP_ROOT')) {
|
|
return;
|
|
}
|
|
$pattern = APP_ROOT . '/modules/*/module.php';
|
|
foreach (glob($pattern) ?: [] as $file) {
|
|
$data = @include $file;
|
|
if (is_array($data) && isset($data['slug'])) {
|
|
self::$registry[$data['slug']] = self::normalize($data);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lee la tabla system_modules y sobreescribe campos controlados por el admin.
|
|
*/
|
|
private static function mergeFromDatabase(): void
|
|
{
|
|
try {
|
|
$db = Database::getInstance();
|
|
$pdo = $db->getConnection();
|
|
|
|
// Verificar si existe la tabla
|
|
$check = $pdo->query("SHOW TABLES LIKE 'system_modules'");
|
|
if (!$check || $check->rowCount() === 0) {
|
|
return;
|
|
}
|
|
|
|
$rows = $pdo->query(
|
|
"SELECT slug, name, icon, category, route, is_active, sort_order, oleada, description
|
|
FROM system_modules ORDER BY sort_order ASC"
|
|
)->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
foreach ($rows as $row) {
|
|
$slug = $row['slug'];
|
|
if (isset(self::$registry[$slug])) {
|
|
// Actualizar solo los campos que maneja el admin
|
|
self::$registry[$slug]['is_active'] = (bool)(int)$row['is_active'];
|
|
self::$registry[$slug]['sort_order'] = (int)$row['sort_order'];
|
|
// Si en BD hay datos más completos, usar los de BD
|
|
if (!empty($row['name'])) self::$registry[$slug]['name'] = $row['name'];
|
|
if (!empty($row['icon'])) self::$registry[$slug]['icon'] = $row['icon'];
|
|
if (!empty($row['category'])) self::$registry[$slug]['category'] = $row['category'];
|
|
if (!empty($row['route'])) self::$registry[$slug]['route'] = $row['route'];
|
|
if (!empty($row['description'])) self::$registry[$slug]['description'] = $row['description'];
|
|
} else {
|
|
// Módulo solo en BD (sin module.php local)
|
|
self::$registry[$slug] = self::normalize([
|
|
'slug' => $slug,
|
|
'name' => $row['name'],
|
|
'icon' => $row['icon'],
|
|
'category' => $row['category'],
|
|
'route' => $row['route'],
|
|
'is_active' => (bool)(int)$row['is_active'],
|
|
'sort_order' => (int)$row['sort_order'],
|
|
'oleada' => (int)$row['oleada'],
|
|
'description' => $row['description'],
|
|
]);
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
// BD no disponible → seguir con datos de archivos
|
|
error_log("ModuleRegistry DB merge error: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Agrega módulos del array SYSTEM_MODULES que no estén ya registrados.
|
|
* Garantiza compatibilidad si no se ejecutó la migración 004.
|
|
*/
|
|
private static function fillFromConstant(): void
|
|
{
|
|
if (!defined('SYSTEM_MODULES')) {
|
|
return;
|
|
}
|
|
foreach (SYSTEM_MODULES as $slug => $name) {
|
|
if (!isset(self::$registry[$slug])) {
|
|
self::$registry[$slug] = self::normalize([
|
|
'slug' => $slug,
|
|
'name' => $name,
|
|
'icon' => 'fas fa-cube',
|
|
'category' => 'sistema',
|
|
'route' => null,
|
|
'is_active' => true,
|
|
'sort_order' => 99,
|
|
'oleada' => 0,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normaliza un descriptor garantizando que todos los campos existen.
|
|
*/
|
|
private static function normalize(array $data): array
|
|
{
|
|
return [
|
|
'slug' => $data['slug'] ?? '',
|
|
'name' => $data['name'] ?? ucfirst($data['slug'] ?? ''),
|
|
'icon' => $data['icon'] ?? 'fas fa-cube',
|
|
'category' => $data['category'] ?? 'sistema',
|
|
'route' => $data['route'] ?? null,
|
|
'is_active' => (bool)($data['is_active'] ?? true),
|
|
'sort_order' => (int)($data['sort_order'] ?? 99),
|
|
'oleada' => (int)($data['oleada'] ?? 0),
|
|
'description' => $data['description'] ?? '',
|
|
'links' => $data['links'] ?? [],
|
|
];
|
|
}
|
|
}
|