diff --git a/config/config.php b/config/config.php index 26d5c98..bd7ff17 100644 --- a/config/config.php +++ b/config/config.php @@ -62,6 +62,7 @@ define('SYSTEM_MODULES', [ // ── Sistema ────────────────────────────────────────────────────────────── 'usuarios' => 'Gestión de Usuarios', 'enfermero_portal' => 'Portal Enfermero', + 'soporte' => 'Soporte y Documentación', // ── Oleada 1 — Turnero ─────────────────────────────────────────────────── 'turnero' => 'Turnero', // ── Oleada 2 — pendiente ───────────────────────────────────────────────── diff --git a/modules/soporte/DocIndex.php b/modules/soporte/DocIndex.php new file mode 100644 index 0000000..c8b5c39 --- /dev/null +++ b/modules/soporte/DocIndex.php @@ -0,0 +1,131 @@ +/-.md → el prefijo NN solo ordena, no se muestra + * El título sale del primer encabezado `# ` del archivo. + */ + +final class DocIndex +{ + /** Secciones, en orden de aparición. `admin` = solo administradores. */ + public const SECCIONES = [ + 'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false, + 'resumen' => 'Cómo usar el sistema, paso a paso, según tu rol.'], + 'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true, + 'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'], + 'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true, + 'resumen' => 'Cómo está armado el sistema y por qué.'], + 'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true, + 'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'], + ]; + + public static function dir(): string + { + return __DIR__ . '/docs'; + } + + /** ¿El usuario actual puede ver esta sección? */ + public static function puedeVer(string $seccion): bool + { + $cfg = self::SECCIONES[$seccion] ?? null; + if (!$cfg) return false; + if (!$cfg['admin']) return true; + return in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true); + } + + /** Secciones visibles para el usuario actual. */ + public static function seccionesVisibles(): array + { + return array_filter( + self::SECCIONES, + fn($s) => self::puedeVer($s), + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]] + * Solo incluye lo que el usuario puede ver. + */ + public static function arbol(): array + { + $arbol = []; + foreach (self::seccionesVisibles() as $sec => $cfg) { + $ruta = self::dir() . '/' . $sec; + if (!is_dir($ruta)) continue; + + $docs = []; + foreach (glob($ruta . '/*.md') ?: [] as $archivo) { + $base = basename($archivo, '.md'); + $slug = preg_replace('/^\d+-/', '', $base); + $docs[] = [ + 'slug' => $slug, + 'titulo' => self::titulo($archivo, $slug), + 'archivo' => $archivo, + 'orden' => $base, + ]; + } + usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden'])); + + $arbol[$sec] = $cfg + ['docs' => $docs]; + } + return $arbol; + } + + /** Ruta del archivo pedido, o null si no existe o no hay acceso. */ + public static function resolver(string $seccion, string $slug): ?string + { + if (!self::puedeVer($seccion)) return null; + // Evitar traversal: los slugs son [a-z0-9-] + if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null; + + foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) { + if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) === $slug) return $archivo; + } + return null; + } + + /** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */ + private static function titulo(string $archivo, string $slugFallback): string + { + $fh = @fopen($archivo, 'r'); + if ($fh) { + $lineas = 0; + while (($l = fgets($fh)) !== false && $lineas++ < 30) { + if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); } + } + fclose($fh); + } + return ucfirst(str_replace('-', ' ', $slugFallback)); + } + + /** + * Índice para el buscador: un registro por documento con su texto plano. + * Solo incluye secciones visibles para el usuario actual. + */ + public static function indiceBusqueda(): array + { + $out = []; + foreach (self::arbol() as $sec => $cfg) { + foreach ($cfg['docs'] as $doc) { + $texto = @file_get_contents($doc['archivo']) ?: ''; + // Aplanar: sin marcas, sin saltos, compacto + $texto = preg_replace('/```.*?```/s', ' ', $texto); + $texto = preg_replace('/[#>*_`|-]+/', ' ', $texto); + $texto = preg_replace('/\s+/', ' ', $texto); + $out[] = [ + 's' => $sec, + 'u' => $doc['slug'], + 't' => $doc['titulo'], + 'c' => $cfg['titulo'], + 'x' => mb_substr(trim($texto), 0, 4000), + ]; + } + } + return $out; + } +} diff --git a/modules/soporte/Generadores.php b/modules/soporte/Generadores.php new file mode 100644 index 0000000..0c4acb9 --- /dev/null +++ b/modules/soporte/Generadores.php @@ -0,0 +1,220 @@ + No se pudo generar esta sección: ' . $e->getMessage(); + } + }, $md); + } + + private static function pdo(): PDO + { + return Database::getInstance()->getConnection(); + } + + private static function raiz(): string + { + return dirname(__DIR__, 2); + } + + // ── Módulos registrados ─────────────────────────────────────────── + private static function genModulos(): string + { + $dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: []; + $filas = []; + foreach ($dirs as $dir) { + $slug = basename($dir); + $meta = ['name' => $slug, 'description' => '', 'oleada' => '']; + $mf = $dir . '/module.php'; + if (is_file($mf)) { + try { + $m = @include $mf; + if (is_array($m)) $meta = $m + $meta; + } catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ } + } + $vistas = count(glob($dir . '/views/*.php') ?: []); + $apis = count(glob($dir . '/api/*.php') ?: []); + $enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES); + $filas[] = [ + '`' . $slug . '`', + $meta['name'] ?? $slug, + $vistas ?: '—', + $apis ?: '—', + $enSys ? 'sí' : 'no', + $meta['description'] ?? '', + ]; + } + usort($filas, fn($a, $b) => strcmp($a[0], $b[0])); + return self::tabla( + ['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'], + $filas + ) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n"; + } + + // ── Endpoints por módulo ────────────────────────────────────────── + private static function genEndpoints(): string + { + $out = ''; + foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) { + $slug = basename(dirname($dir)); + $files = glob($dir . '/*.php') ?: []; + if (!$files) continue; + sort($files); + + $filas = []; + foreach ($files as $f) { + $nombre = basename($f); + if (str_starts_with($nombre, '_')) continue; // helpers internos + $filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)]; + } + if (!$filas) continue; + $out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n"; + } + return $out ?: '> Sin endpoints.'; + } + + // ── Tablas de la base de datos ──────────────────────────────────── + private static function genTablas(): string + { + $rows = self::pdo()->query( + "SELECT table_name, table_rows, table_comment + FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' + ORDER BY table_name" + )->fetchAll(PDO::FETCH_ASSOC); + + // Agrupar por prefijo para que se lea por dominio + $grupos = []; + foreach ($rows as $r) { + $t = $r['table_name']; + $pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras'; + $grupos[$pfx][] = $r; + } + ksort($grupos); + + $out = ''; + foreach ($grupos as $pfx => $tablas) { + $out .= "\n### " . $pfx . "\n\n"; + $filas = []; + foreach ($tablas as $t) { + $cols = self::pdo()->prepare( + "SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ?" + ); + $cols->execute([$t['table_name']]); + $filas[] = [ + '`' . $t['table_name'] . '`', + (string)(int)$cols->fetchColumn(), + number_format((int)$t['table_rows'], 0, ',', '.'), + $t['table_comment'] ?: '', + ]; + } + $out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n"; + } + return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n"; + } + + // ── Roles y permisos efectivos ──────────────────────────────────── + private static function genRoles(): string + { + $roles = self::pdo()->query( + "SELECT r.id, r.slug, r.name, r.description, + (SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios + FROM roles r ORDER BY r.slug" + )->fetchAll(PDO::FETCH_ASSOC); + + $mods = self::pdo()->query( + "SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug" + )->fetchAll(PDO::FETCH_ASSOC); + + $porRol = []; + foreach ($mods as $m) { + $porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : ''); + } + + $filas = []; + foreach ($roles as $r) { + $lista = $porRol[(int)$r['id']] ?? []; + $filas[] = [ + '`' . $r['slug'] . '`', + $r['name'] ?: '', + (string)(int)$r['usuarios'], + $lista ? implode(', ', $lista) : '—', + ]; + } + return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas) + . "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n"; + } + + // ── Servicios y clases del núcleo ───────────────────────────────── + private static function genServicios(): string + { + $out = ''; + foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) { + $files = glob(self::raiz() . '/' . $dir . '/*.php') ?: []; + if (!$files) continue; + sort($files); + $filas = []; + foreach ($files as $f) { + $filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)]; + } + $out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n"; + } + return $out; + } + + /** Primera línea con contenido del bloque docblock inicial de un archivo. */ + private static function resumenPhpDoc(string $archivo): string + { + $fh = @fopen($archivo, 'r'); + if (!$fh) return ''; + $n = 0; + $ruta = null; + while (($l = fgets($fh)) !== false && $n++ < 25) { + $l = trim($l); + if (!str_starts_with($l, '*')) continue; + $l = trim(ltrim($l, '*/ ')); + if ($l === '') continue; + // Saltar la línea que solo repite la ruta del archivo + if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; } + if (str_starts_with($l, '@')) break; + fclose($fh); + return $l; + } + fclose($fh); + return ''; + } + + /** Arma una tabla Markdown escapando los separadores del contenido. */ + private static function tabla(array $encabezados, array $filas): string + { + $esc = fn($v) => str_replace('|', '\\|', (string)$v); + $md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n"; + $md .= '|' . str_repeat('---|', count($encabezados)) . "\n"; + foreach ($filas as $f) { + $md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n"; + } + return $md; + } +} diff --git a/modules/soporte/Markdown.php b/modules/soporte/Markdown.php new file mode 100644 index 0000000..c364c29 --- /dev/null +++ b/modules/soporte/Markdown.php @@ -0,0 +1,238 @@ +' + . htmlspecialchars(implode("\n", $buffer), ENT_QUOTES) + . ''; + continue; + } + + // ── Línea en blanco ─────────────────────────────────────── + if (trim($linea) === '') { $i++; continue; } + + // ── Regla horizontal ────────────────────────────────────── + if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) { + $html .= '
'; + $i++; + continue; + } + + // ── Encabezado ──────────────────────────────────────────── + if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) { + $nivel = strlen($m[1]); + $texto2 = trim($m[2]); + $slug = self::slug($texto2); + $html .= "" . self::inline($texto2) . ""; + $i++; + continue; + } + + // ── Tabla ───────────────────────────────────────────────── + if (strpos($linea, '|') !== false + && isset($lineas[$i + 1]) + && preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1])) { + [$tabla, $i] = self::tabla($lineas, $i); + $html .= $tabla; + continue; + } + + // ── Cita ────────────────────────────────────────────────── + if (preg_match('/^>\s?(.*)$/', $linea)) { + $buffer = []; + while ($i < $n && preg_match('/^>\s?(.*)$/', $lineas[$i], $m2)) { + $buffer[] = $m2[1]; + $i++; + } + $html .= '
' . self::render(implode("\n", $buffer)) . '
'; + continue; + } + + // ── Lista (con o sin numerar, admite anidación) ─────────── + if (preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $linea)) { + [$lista, $i] = self::lista($lineas, $i, 0); + $html .= $lista; + continue; + } + + // ── Párrafo ─────────────────────────────────────────────── + $buffer = []; + while ($i < $n + && trim($lineas[$i]) !== '' + && !preg_match('/^(#{1,6}\s|```|>|\s*([-*+]|\d+\.)\s|(-{3,}|\*{3,}|_{3,})\s*$)/', $lineas[$i]) + && !(strpos($lineas[$i], '|') !== false + && isset($lineas[$i + 1]) + && preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1]))) { + $buffer[] = $lineas[$i]; + $i++; + } + if ($buffer) $html .= '

' . self::inline(implode(' ', $buffer)) . '

'; + } + + return $html; + } + + /** Construye una lista, recursivamente para los niveles anidados. */ + private static function lista(array $lineas, int $i, int $sangriaBase): array + { + $n = count($lineas); + preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i], $m0); + $ordenada = !in_array($m0[2], ['-', '*', '+'], true); + $tag = $ordenada ? 'ol' : 'ul'; + $html = "<{$tag}>"; + + while ($i < $n) { + if (trim($lineas[$i]) === '') { + // Una línea vacía corta la lista salvo que siga otro ítem + if (isset($lineas[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i + 1])) { + $i++; + continue; + } + break; + } + if (!preg_match('/^(\s*)([-*+]|\d+\.)\s+(.*)$/', $lineas[$i], $m)) break; + + $sangria = strlen($m[1]); + if ($sangria < $sangriaBase) break; + + if ($sangria > $sangriaBase) { + [$sub, $i] = self::lista($lineas, $i, $sangria); + // Colgar la sublista del último ítem abierto + $html = preg_replace('/<\/li>$/', '', $html) . $sub . ''; + continue; + } + + $html .= '
  • ' . self::inline($m[3]) . '
  • '; + $i++; + } + + return [$html . "", $i]; + } + + /** Construye una tabla a partir de la fila de encabezado. */ + private static function tabla(array $lineas, int $i): array + { + $n = count($lineas); + $celdas = fn(string $l) => array_map('trim', explode('|', trim($l, " \t|"))); + + $encabezado = $celdas($lineas[$i]); + $alineacion = array_map(function ($c) { + $c = trim($c); + if (str_starts_with($c, ':') && str_ends_with($c, ':')) return 'center'; + if (str_ends_with($c, ':')) return 'right'; + return 'left'; + }, $celdas($lineas[$i + 1])); + $i += 2; + + $html = '
    '; + foreach ($encabezado as $k => $c) { + $a = $alineacion[$k] ?? 'left'; + $html .= ''; + } + $html .= ''; + + while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) { + $fila = $celdas($lineas[$i]); + $html .= ''; + foreach ($encabezado as $k => $_) { + $a = $alineacion[$k] ?? 'left'; + $html .= ''; + } + $html .= ''; + $i++; + } + + return [$html . '
    ' . self::inline($c) . '
    ' . self::inline($fila[$k] ?? '') . '
    ', $i]; + } + + /** + * Formato dentro de una línea. Se escapa primero y el código en línea se + * aparta con marcadores para que su contenido no reciba más formato. + */ + private static function inline(string $texto): string + { + $codigos = []; + $texto = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codigos) { + $codigos[] = '' . htmlspecialchars($m[1], ENT_QUOTES) . ''; + return "\x00" . (count($codigos) - 1) . "\x00"; + }, $texto); + + $texto = htmlspecialchars($texto, ENT_QUOTES); + + // Enlaces [texto](destino) — solo http(s), rutas internas y anclas + $texto = preg_replace_callback( + '/\[([^\]]+)\]\(([^)\s]+)\)/', + function ($m) { + $url = html_entity_decode($m[2], ENT_QUOTES); + if (!preg_match('#^(https?://|/|\?|\#)#', $url)) return $m[0]; + $ext = str_starts_with($url, 'http') ? ' target="_blank" rel="noopener"' : ''; + return '' . $m[1] . ''; + }, + $texto + ); + + $texto = preg_replace('/\*\*([^*]+)\*\*/', '$1', $texto); + $texto = preg_replace('/(?$1', $texto); + $texto = preg_replace('/(?$1', $texto); + + // Restaurar código en línea + return preg_replace_callback('/\x00(\d+)\x00/', fn($m) => $codigos[(int)$m[1]] ?? '', $texto); + } + + /** Ancla estable para un encabezado. */ + public static function slug(string $texto): string + { + $t = strtr(mb_strtolower(strip_tags($texto)), [ + 'á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ñ'=>'n','ü'=>'u', + ]); + $t = preg_replace('/[^a-z0-9]+/', '-', $t); + return trim($t, '-'); + } + + /** Extrae los encabezados h2/h3 para la tabla de contenidos lateral. */ + public static function indice(string $texto): array + { + $out = []; + foreach (preg_split('/\R/', $texto) as $l) { + if (preg_match('/^(#{2,3})\s+(.*)$/', $l, $m)) { + $t = trim($m[2]); + $out[] = ['nivel' => strlen($m[1]), 'texto' => $t, 'slug' => self::slug($t)]; + } + } + return $out; + } +} diff --git a/modules/soporte/docs/arquitectura/10-vision-general.md b/modules/soporte/docs/arquitectura/10-vision-general.md new file mode 100644 index 0000000..7e40761 --- /dev/null +++ b/modules/soporte/docs/arquitectura/10-vision-general.md @@ -0,0 +1,61 @@ +# Visión general + +Este sistema es el ERP del **Laboratorio Clínico Ximena Caicedo**. Nació como un bot de WhatsApp y creció hasta cubrir la operación diaria del laboratorio: turnos presenciales, toma de muestras, domicilios, órdenes médicas, formularios firmados digitalmente y facturación del día. + +## Qué resuelve + +| Área | Qué hace el sistema | +|---|---| +| Atención por WhatsApp | Bot que responde, agenda, envía consentimientos y encuestas | +| Turnero presencial | Kiosko, recepción, estaciones de toma de muestras, pantallas de TV | +| Domicilios | Agendamiento y asignación de enfermeros a visitas domiciliarias | +| Formularios | Consentimientos y fichas clínicas firmadas digitalmente | +| Laboratorio | Pacientes, órdenes médicas, exámenes, EPS, empresas, médicos | + +## Las tres capas + +El código está organizado en tres niveles, de lo más general a lo más específico: + +``` +erp.php punto de entrada único del ERP + └── core/App.php arranque, sesión, enrutamiento, control de acceso + └── modules//views/.php la pantalla concreta + └── modules//api/*.php endpoints que consume por fetch +``` + +Debajo de todo eso están los **servicios** (`services/`), que encapsulan lo que habla con el mundo exterior — sobre todo la API de WhatsApp — y las **clases de dominio** (`classes/lab/`), que concentran las reglas de negocio de pacientes, domicilios, órdenes y formularios. + +## Convivencia con el sistema anterior + +Hay dos generaciones de código funcionando a la vez, y es intencional: + +- **Archivos sueltos en la raíz** (`lab_domicilios.php`, `index.php`, `ver_formulario_enviado.php`, …). Es el sistema original. Siguen siendo el código real de muchas pantallas. +- **Módulos en `modules/`**. Es la estructura nueva. Algunos módulos son pantallas completas (turnero, registro de exámenes); otros son apenas un puente que incluye el archivo viejo. + +Un ejemplo de puente, `modules/lab_domicilios/views/index.php`: + +```php +require_once APP_ROOT . '/lab_domicilios.php'; +``` + +La migración es gradual y a propósito: mover una pantalla al nuevo esquema no obliga a mover las demás. Al leer el código, **el archivo de la raíz suele ser el que manda**; el módulo solo aporta el registro en el menú y el control de acceso. + +## Stack + +| Componente | Detalle | +|---|---| +| Lenguaje | PHP 7.4+ (en producción corre sobre versiones más recientes) | +| Base de datos | MariaDB 11.8 | +| Frontend | HTML server-side + JavaScript sin framework; Bootstrap 5 y Font Awesome | +| Mensajería | WhatsApp Cloud API (Meta) | +| IA | Google Gemini Flash — asistente LIA del dashboard del turnero | +| Dependencias | Predis, Monolog, Guzzle, phpdotenv (vía Composer) | + +No hay build step ni framework de frontend: las vistas son PHP que emite HTML y el JavaScript va embebido en la misma vista. Es deliberado — mantiene el despliegue en un simple `git pull`. + +## Por dónde seguir + +- [Enrutamiento y módulos](?m=soporte&v=documentacion&s=arquitectura&d=enrutamiento) — cómo una URL llega a una pantalla +- [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) — quién ve qué +- [Modelo de datos](?m=soporte&v=documentacion&s=arquitectura&d=modelo-de-datos) — las 91 tablas, agrupadas +- [Integración con WhatsApp](?m=soporte&v=documentacion&s=arquitectura&d=whatsapp) — el punto más delicado del sistema diff --git a/modules/soporte/docs/arquitectura/20-enrutamiento.md b/modules/soporte/docs/arquitectura/20-enrutamiento.md new file mode 100644 index 0000000..eca422d --- /dev/null +++ b/modules/soporte/docs/arquitectura/20-enrutamiento.md @@ -0,0 +1,123 @@ +# Enrutamiento y módulos + +Cómo una URL termina ejecutando una pantalla concreta, y qué hace falta para agregar un módulo nuevo. + +## El recorrido de una petición + +``` +GET /erp.php?m=turnero&v=historial + │ + ├── erp.php define APP_ROOT y llama App::run() + │ + ├── App::boot() carga config, abre sesión, fija zona horaria + │ + ├── Router decide módulo y vista + │ ├── 1º intenta la ruta limpia: /turnero/historial + │ └── 2º cae a los parámetros: ?m=turnero&v=historial + │ + ├── Router::resolveFile() + │ └── modules/turnero/views/historial.php ¿existe? si no → 404 + │ + ├── Rbac::hasModule('turnero') ¿tiene acceso? si no → 403 + │ + └── include del archivo de la vista +``` + +El punto clave: **la ruta es literalmente la ubicación del archivo**. `?m=turnero&v=historial` carga `modules/turnero/views/historial.php`. No hay tabla de rutas ni configuración intermedia. + +## Validación de la URL + +`Router` acepta como módulo y vista solo `[a-zA-Z0-9_]`, máximo 64 caracteres. Cualquier cosa fuera de ese patrón se descarta silenciosamente y se reemplaza por el valor por defecto (`dashboard` / `index`). Eso cierra la puerta a recorrer directorios con `../`. + +## Rutas públicas + +Casi todo exige sesión. Las excepciones están fijas en `core/Router.php`: + +| Ruta | Por qué es pública | +|---|---| +| `turnero/display` | Pantalla de TV en sala de espera; no hay quién inicie sesión | +| `turnero/kiosko` | El paciente saca su turno solo | + +Cualquier otra combinación pasa por el control de acceso. + +> Ojo: `isPublic()` solo omite la verificación **de módulo**. La sesión se maneja aparte, dentro de cada vista. + +## Registrar un módulo nuevo + +Hacen falta tres cosas. Si falta alguna, el módulo no aparece o da 403. + +**1. La carpeta y al menos una vista** + +``` +modules/mimodulo/ + module.php + views/index.php + api/ (opcional) +``` + +**2. El descriptor `module.php`** — devuelve un arreglo: + +```php + 'mimodulo', + 'name' => 'Mi Módulo', + 'icon' => 'fas fa-cube', + 'category' => 'lab', + 'route' => '/erp.php?m=mimodulo&v=index', + 'is_active' => true, + 'sort_order' => 50, + 'description' => 'Para qué sirve', + 'links' => [ + ['name' => 'Inicio', 'icon' => 'fas fa-home', 'route' => '/erp.php?m=mimodulo&v=index'], + ], +]; +``` + +`links` son las entradas que salen en el menú lateral. El descriptor se ejecuta como PHP, así que puede armar los enlaces según el rol de quien mira — el turnero lo hace: muestra escritorios distintos a recepcionistas y bacteriólogos. + +**3. El registro en `SYSTEM_MODULES`** (`config/config.php`) + +```php +define('SYSTEM_MODULES', [ + ... + 'mimodulo' => 'Mi Módulo', +]); +``` + +Estar acá es lo que **activa la verificación de permisos**. Un módulo ausente de esta lista no se valida y queda accesible para cualquier sesión. + +**4. Dar acceso a los roles** — sin esto, todos reciben 403: + +```sql +INSERT INTO role_modules (role_id, module_slug, permission, can_view) +SELECT id, 'mimodulo', 'write', 1 FROM roles WHERE slug IN ('admin','superadmin'); +``` + +> Los módulos de la sesión se cargan **al iniciar sesión**, desde `role_id`. Después de tocar `role_modules`, el usuario afectado tiene que volver a entrar para que el cambio surta efecto. + +## Módulos actuales + +{{modulos}} + +## Vistas y layout + +Una vista se escribe así: + +```php +require_once APP_ROOT . '/config/config.php'; +if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; } + +Layout::open('Título de la pantalla', 'fas fa-icono'); +// HTML, CSS y JS de la pantalla +Layout::close(); +``` + +`Layout::open()` emite el ``, la barra superior y el menú lateral — que construye leyendo los `module.php` de los módulos a los que el usuario tiene acceso. `Layout::close()` cierra el documento. + +## Endpoints + +Cada módulo puede tener su carpeta `api/`. Son archivos PHP sueltos que devuelven JSON y se consumen por `fetch` desde las vistas. No pasan por `Router`: se invocan por su ruta real (`modules/turnero/api/get_historial.php`). + +Por convención, `api/_helpers.php` de cada módulo concentra lo común — conexión, lectura del cuerpo JSON, respuestas `jsonOk()` / `jsonError()` y la verificación de acceso. + +Los archivos que empiezan con guión bajo son de uso interno y no se llaman directamente desde el navegador. diff --git a/modules/soporte/docs/arquitectura/30-roles-y-permisos.md b/modules/soporte/docs/arquitectura/30-roles-y-permisos.md new file mode 100644 index 0000000..0b7b588 --- /dev/null +++ b/modules/soporte/docs/arquitectura/30-roles-y-permisos.md @@ -0,0 +1,113 @@ +# Roles y permisos + +Quién puede ver y hacer qué. Es el punto donde más seguido se cometen errores, así que conviene entenderlo completo. + +## Las dos columnas de un usuario + +En `admin_users` conviven dos campos que parecen redundantes y **no lo son**: + +| Columna | Para qué se usa | +|---|---| +| `role` | Texto del rol (`admin`, `bacteriologo`, …). Lo consultan las vistas para decidir qué mostrar | +| `role_id` | Apunta a `roles.id`. Es de donde se **cargan los módulos** al iniciar sesión | + +> **Hay que mantener las dos sincronizadas.** Cambiar solo `role` deja al usuario con los permisos viejos, porque el acceso real sale de `role_id`. Este error ya ocurrió: un usuario cambió de rol, la interfaz mostraba el rol nuevo y los módulos seguían siendo los anteriores. + +Al cambiar el rol de alguien, actualizá las dos a la vez: + +```sql +UPDATE admin_users + SET role = 'lab_recepcion', + role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion') + WHERE id = 12; +``` + +## Cómo se arma el acceso al iniciar sesión + +`authenticateUser()` (`config/config.php`) valida la contraseña y arma la sesión: + +``` +admin_users.role_id + └── role_modules → lista de module_slug + permission + └── $_SESSION['admin_user']['modules'] (qué módulos ve) + $_SESSION['admin_user']['module_permissions'] (read o write en cada uno) +``` + +**Esto ocurre una sola vez, al entrar.** Cualquier cambio en `role_modules` no afecta a las sesiones abiertas: el usuario tiene que cerrar sesión y volver a entrar. + +## Las dos preguntas del control de acceso + +```php +hasModule('lab_domicilios') // ¿puede entrar al módulo? +hasModuleWrite('lab_domicilios') // ¿puede modificar, o solo mirar? +``` + +- `hasModule()` mira si el slug está en la lista de módulos de la sesión. +- `hasModuleWrite()` mira `module_permissions[slug] === 'write'`. Los administradores siempre pueden escribir. + +Una vista típica lo usa así: + +```php +$puedeEscribir = hasModuleWrite('lab_domicilios'); +... + +``` + +## La columna que manda es `permission` + +`role_modules` tiene dos formas de expresar lo mismo, y solo una se usa: + +| Columnas | ¿Se usan? | +|---|---| +| `permission` (`read` / `write`) | **Sí.** Es lo que lee `hasModuleWrite()` | +| `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` | No las lee el control de acceso | + +> Poner `can_edit = 0` **no impide editar**. Para dejar un módulo en solo lectura hay que fijar `permission = 'read'`. Las columnas `can_*` quedaron de un diseño anterior; conviene mantenerlas coherentes por prolijidad, pero no protegen nada. + +Solo lectura de verdad: + +```sql +UPDATE role_modules SET permission = 'read' + WHERE role_id = 1030 AND module_slug IN ('lab_domicilios', 'lab_ordenes'); +``` + +## Roles actuales + +{{roles}} + +## Sesiones sin `role_id` + +Hay dos casos heredados que siguen contemplados en el código: + +- **`modules` nulo y rol `admin`** → acceso total. Cubre usuarios anteriores al sistema de roles. +- **Rol `enfermero` sin `role_id`** → recibe `enfermero_portal` y `lab_formularios` de forma fija. + +## Verificaciones adicionales + +El control por módulo no siempre alcanza. Varias pantallas agregan sus propias reglas: + +| Dónde | Regla | +|---|---| +| `enfermero_portal.php` | Solo `admin`, `superadmin` y `enfermero` | +| `api/lab/save_domicilio.php` | Un enfermero solo edita domicilios que creó **o** que tiene asignados | +| `api/lab/firmar_profesional.php` | Un enfermero solo firma envíos propios | +| `modules/turnero/api/_helpers.php` | `requireTurnero()` en todos los endpoints del turnero | +| `modules/turnero/module.php` | El menú cambia según rol y según la IP del equipo | + +Al agregar un endpoint que modifica datos, **no alcanza con confiar en que la vista ocultó el botón**: el endpoint tiene que verificar por su cuenta. + +## Diagnóstico rápido + +Alguien reporta que no ve un módulo o que puede editar lo que no debería: + +```sql +-- Qué rol tiene realmente y si las dos columnas coinciden +SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real + FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id + WHERE u.username = 'usuario'; + +-- Qué módulos le da ese rol +SELECT module_slug, permission FROM role_modules WHERE role_id = ; +``` + +Si los datos se ven bien y el usuario sigue sin acceso: **no ha vuelto a iniciar sesión**. diff --git a/modules/soporte/docs/arquitectura/40-modelo-de-datos.md b/modules/soporte/docs/arquitectura/40-modelo-de-datos.md new file mode 100644 index 0000000..eaa0226 --- /dev/null +++ b/modules/soporte/docs/arquitectura/40-modelo-de-datos.md @@ -0,0 +1,85 @@ +# Modelo de datos + +Las tablas están agrupadas por prefijo, y el prefijo dice a qué dominio pertenecen. + +| Prefijo | Dominio | +|---|---| +| `lab_` | Laboratorio: pacientes, domicilios, órdenes, formularios, configuración | +| `turnero_` | Turnos presenciales: sesiones, turnos, solicitudes, muestras, consentimientos | +| `exam_` | Catálogo de exámenes y sus consentimientos asociados | +| `terms_` | Términos y condiciones del bot y su historial de aceptaciones | +| `admin_`, `roles`, `role_modules` | Usuarios y permisos | +| resto | Conversaciones de WhatsApp, plantillas, logs, configuración del sistema | + +## Los cuatro núcleos + +### Turno presencial + +Es la cadena más larga del sistema. Un paciente entra al laboratorio y genera esto: + +``` +turnero_sesiones una fila por día de operación + └── turnero_turnos el turno del paciente (código, estado, tiempos) + ├── turnero_solicitudes qué se le va a hacer y cuánto se cobró + │ ├── turnero_examen_items exámenes pedidos + │ └── turnero_muestras muestras a recibir + ├── turnero_consentimientos formularios a firmar + └── turnero_comentarios notas del personal +``` + +`turnero_turnos.estado` gobierna el flujo: + +``` +espera → en_recepcion → en_espera_lugar → en_servicio → finalizado + ausente / cancelado +``` + +Solo los turnos **finalizados** cuentan como facturación real; los que están en estados intermedios se reportan aparte como "en proceso". Ausentes y cancelados no cuentan. + +### Domicilio + +``` +lab_domicilios + ├── lab_asignaciones qué enfermero lo atiende + ├── lab_domicilio_notas seguimiento + └── lab_domicilio_pagos cobros +``` + +### Formulario firmado + +Un mismo formulario (`lab_formularios`) se firma por dos vías distintas, y cada una guarda en su propia tabla: + +| Vía | Tabla | Token | +|---|---|---| +| Turnero | `turnero_consentimientos` | UUID (`?token=`) | +| Domicilios y envíos sueltos | `lab_form_envios` | 64 caracteres hex (`?t=`) | + +Las dos las muestra `ver_formulario_enviado.php`, que distingue por el **formato del token**. Es la razón de que existan dos parámetros distintos para lo que parece lo mismo. + +La definición del formulario vive en `lab_formularios.esquema`, un JSON con la lista de campos. Las respuestas quedan en `datos_respuestas` (turnero) o `datos_cliente` (envíos), también JSON. + +### Conversación de WhatsApp + +``` +users / conversations el contacto y su hilo + ├── messages cada mensaje + ├── terms_acceptance aceptación de términos + └── message_templates plantillas aprobadas por Meta (caché local) +``` + +## Convenciones + +- **Timestamps**: `creado_at` / `created_at` según la época en que se creó la tabla. No hay una sola convención. +- **Autor**: `creado_por` guarda `admin_users.id`. Varias tablas lo agregaron después, así que las filas viejas lo tienen en `NULL`. +- **Borrado**: casi todo es borrado físico. No hay *soft delete* generalizado. +- **JSON**: se usa bastante (`esquema`, `datos_respuestas`, `pagos_detalle`, `items_precio`). Guardado como `longtext`, no como tipo `JSON` nativo. + +## Cambios de esquema + +Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`. La convención del repositorio es que sean **idempotentes** — `IF NOT EXISTS` y guardas en los `UPDATE`/`INSERT` — para poder correrlas más de una vez sin daño. + +> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si tocás el esquema, dejá el archivo. + +## Inventario completo + +{{tablas}} diff --git a/modules/soporte/docs/arquitectura/50-whatsapp.md b/modules/soporte/docs/arquitectura/50-whatsapp.md new file mode 100644 index 0000000..02c365d --- /dev/null +++ b/modules/soporte/docs/arquitectura/50-whatsapp.md @@ -0,0 +1,85 @@ +# Integración con WhatsApp + +Es la parte del sistema con más piezas fuera de nuestro control. Buena parte de la configuración vive **en Meta**, no en la base de datos, y eso explica varios comportamientos que de otro modo parecen inexplicables. + +## Dos números, una misma cuenta + +El laboratorio opera con dos líneas sobre la misma cuenta de WhatsApp Business (WABA): + +| Canal | Configuración | Para qué | +|---|---|---| +| Principal | `whatsapp_phone_number_id` | Bot de atención general | +| Turnero | `whatsapp_phone_number_id_turnero` | Consentimientos, encuestas y avisos de turno | + +Se elige al construir el servicio: + +```php +$wa = new WhatsAppService(); // línea principal +$wa = new WhatsAppService('turnero'); // línea del turnero +``` + +> Si un mensaje sale por el número equivocado, casi siempre es porque se instanció sin el canal. Es el mismo WABA y el mismo token: **lo único que cambia es el `phone_number_id`**. + +## Configuración + +Todo en `system_config`: + +| Clave | Qué es | +|---|---| +| `whatsapp_token` | Token de acceso a la API | +| `whatsapp_api_url` | URL base de la Cloud API | +| `whatsapp_business_account_id` | Identificador del WABA | +| `whatsapp_phone_number_id` | Número principal | +| `whatsapp_phone_number_id_turnero` | Número del turnero | +| `webhook_verify_token` | Verificación del webhook | + +## Plantillas: la parte que no controlamos + +Para escribir primero a alguien (fuera de la ventana de 24 horas) hay que usar una **plantilla aprobada por Meta**. `message_templates` guarda una copia local, pero **la copia no manda**: la versión real está en Meta. + +Esto tiene una consecuencia importante y poco intuitiva: + +> **Las URL de los botones viven en la plantilla, no en nuestro código.** + +La plantilla `consentimiento_turno_v2` tiene un botón así: + +``` +https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}} +``` + +Nuestro código solo envía el token como `{{1}}`. Cambiar el código **no cambia** el enlace que recibe el paciente: hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación. + +El único lugar donde sí armamos la URL completa es el **respaldo en texto plano**, que se usa cuando falla el envío por plantilla (`modules/turnero/api/send_consentimiento.php`). + +## Por qué el enlace pasa por dos páginas + +El botón apunta a `form_cliente.php?t=`, pero el consentimiento del turnero lo muestra `ver_formulario_enviado.php?token=`. + +`form_cliente.php` detecta que el token tiene formato UUID —o sea, que viene del turnero— y redirige. Convive así porque la plantilla ya estaba aprobada apuntando a la página de envíos, y cambiarla obliga a otra ronda de aprobación en Meta. + +## Términos y condiciones + +Antes de conversar, el bot exige aceptar los términos. El usuario responde **ACEPTO** o **NO ACEPTO**. + +La URL del documento está escrita en **dos lugares** y hay que cambiarlos juntos: + +| Dónde | Rol | +|---|---| +| `terms_versions.documento_url` | El bot la adjunta al final del mensaje | +| `system_config.terms_message` | Va escrita dentro del texto de bienvenida | + +Se vuelve a pedir la aceptación si: nunca aceptó, pasaron más de 6 meses, o hay una versión nueva con `forzar_reenvio`. + +## El webhook + +Meta envía los mensajes entrantes al webhook, que los registra y se los pasa a `BotService`. Ahí se decide si responde el bot automático o queda para un operador humano, según el estado de la conversación y el horario de atención (`BusinessHoursService`). + +## Qué revisar cuando algo falla + +| Síntoma | Dónde mirar primero | +|---|---| +| El mensaje sale por el número equivocado | Que se haya pasado `'turnero'` al constructor | +| Un enlace llega roto o apunta mal | La plantilla en Meta, no el código | +| No llega ninguna plantilla | Estado de aprobación en el WhatsApp Manager | +| Falla el envío pero llega un texto plano | Es el respaldo actuando: la plantilla falló | +| El bot no responde | `webhook_logs`, y el horario de atención | diff --git a/modules/soporte/docs/operacion/10-runbook.md b/modules/soporte/docs/operacion/10-runbook.md new file mode 100644 index 0000000..ef40ab6 --- /dev/null +++ b/modules/soporte/docs/operacion/10-runbook.md @@ -0,0 +1,167 @@ +# Runbook de incidentes + +Qué hacer cuando algo falla. Ordenado por lo que reporta el usuario, no por la causa. + +--- + +## «No veo un módulo que antes veía» + +O el opuesto: «puedo editar algo que no debería». + +**Casi siempre es una de dos cosas:** el usuario no volvió a iniciar sesión, o `role` y `role_id` quedaron desincronizados. + +```sql +-- 1. ¿Las dos columnas coinciden? +SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real + FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id + WHERE u.username = 'usuario'; + +-- 2. ¿Qué le da ese rol? +SELECT module_slug, permission FROM role_modules WHERE role_id = ; +``` + +Si los datos están bien → **que cierre sesión y vuelva a entrar**. Los permisos se cargan al iniciar sesión, no en cada petición. + +Si `role` y `role_id` no coinciden, actualizá las dos: + +```sql +UPDATE admin_users + SET role = 'lab_recepcion', + role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion') + WHERE id = ; +``` + +> Para dejar un módulo en solo lectura, lo que importa es `permission = 'read'`. Las columnas `can_edit`, `can_create` y demás **no** las lee el control de acceso. + +--- + +## «Un enlace que enviamos por WhatsApp está roto» + +Primero, comprobá si el destino responde: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" "" +``` + +**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revisá si el archivo existe en el dominio actual del sistema. + +Las URL que enviamos viven en lugares distintos según el caso: + +| Enlace | Dónde está definido | +|---|---| +| Botón de consentimiento | **En la plantilla de Meta**, no en el código | +| Documento de términos | `terms_versions.documento_url` **y** `system_config.terms_message` | +| Respaldo texto plano del consentimiento | Se arma con el dominio del servidor | + +> Si el enlace roto es el botón de una plantilla, **cambiar el código no lo arregla**. Hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación. + +Para el documento de términos, hay que cambiar **los dos** lugares a la vez: + +```sql +UPDATE terms_versions + SET documento_url = REPLACE(documento_url, 'dominio.viejo', 'dominio.nuevo') + WHERE documento_url LIKE '%dominio.viejo%'; + +UPDATE system_config + SET config_value = REPLACE(config_value, 'dominio.viejo', 'dominio.nuevo') + WHERE config_value LIKE '%dominio.viejo%'; +``` + +--- + +## «El mensaje salió por el número equivocado» + +Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revisá que el envío haya especificado el canal: + +```php +$wa = new WhatsAppService('turnero'); // no new WhatsAppService() +``` + +--- + +## «LIA responde cortado» + +El asistente del dashboard del turnero tiene tope de salida. Si la respuesta se corta a media frase, ahora avisa con *«respuesta cortada por longitud»*. + +| Qué revisar | Dónde | +|---|---| +| Tope de tokens de salida | `modules/turnero/api/ai_chat.php`, `maxOutputTokens` | +| Presupuesto consumido | `lab_config.lia_tokens_usados` (tope: 1.000.000) | +| Clave configurada | `lab_config.gemini_api_key` | + +Si el presupuesto se agotó, LIA se bloquea y pide contactar a soporte. Para reiniciar el contador: + +```sql +UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados'; +``` + +> El contexto del día se manda completo en **cada** pregunta, así que el gasto por consulta es alto aunque la respuesta sea corta. + +--- + +## «El formulario de tomas prolongadas muestra secciones que no corresponden» + +El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muestra solo las del examen del paciente. Si aparecen de más: + +1. Verificá que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado. +2. Revisá `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron. + +--- + +## «No aparece quién firmó una toma» + +Las firmas de tomas prolongadas registran el profesional **desde el 3 de agosto de 2026**. Los documentos firmados antes no tienen ese dato y **no es recuperable** — no quedó traza en ninguna tabla de auditoría. + +Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, comprobá que el usuario tenga cédula: + +```sql +SELECT id, username, full_name, cedula FROM admin_users WHERE id = ; +``` + +Los enfermeros la toman de `lab_enfermeras.numero_documento`; el resto de `admin_users.cedula`. + +--- + +## «El bot no responde» + +| Revisar | Cómo | +|---|---| +| ¿Llegan los mensajes? | Tabla `webhook_logs` | +| ¿Está en horario? | `BusinessHoursService` — fuera de horario responde distinto | +| ¿La conversación quedó con un operador? | Estado en `conversations`; el bot no interrumpe una atención humana | +| ¿Aceptó los términos? | `users.terms_accepted_at`; sin aceptar, el bot no avanza | + +--- + +## «Un paciente quedó con una muestra pendiente» + +Cuando el paciente vuelve, las muestras pendientes **y rechazadas** de visitas anteriores aparecen automáticamente en la estación de toma de muestras, con la etiqueta *visita anterior* y los exámenes de aquella orden. + +Al recibirla queda registrado en qué turno se completó (`turnero_muestras.recibida_en_turno_id`), y ambos turnos quedan enlazados en el historial y en la bandeja. **El turno original no se modifica**: sigue finalizado como estaba. + +--- + +## Consultas útiles + +```sql +-- Facturación real de hoy (solo turnos finalizados) +SELECT ROUND(SUM(ts.total_cobrado)) AS facturado, COUNT(*) AS turnos + FROM turnero_turnos t + JOIN turnero_solicitudes ts ON ts.turno_id = t.id + JOIN turnero_sesiones s ON s.id = t.sesion_id + WHERE s.fecha = CURDATE() AND t.estado = 'finalizado' AND ts.total_cobrado > 0; + +-- Consentimientos sin firmar +SELECT tc.estado, COUNT(*) FROM turnero_consentimientos tc + JOIN turnero_turnos t ON t.id = tc.turno_id + JOIN turnero_sesiones s ON s.id = t.sesion_id + WHERE s.fecha = CURDATE() GROUP BY tc.estado; + +-- Muestras pendientes acumuladas por paciente +SELECT p.nombre_completo, COUNT(*) AS pendientes + FROM turnero_muestras tm + JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id + JOIN lab_pacientes p ON p.id = ts.paciente_id + WHERE tm.estado IN ('pendiente','rechazada') + GROUP BY p.id ORDER BY pendientes DESC LIMIT 20; +``` diff --git a/modules/soporte/docs/operacion/20-configuraciones-criticas.md b/modules/soporte/docs/operacion/20-configuraciones-criticas.md new file mode 100644 index 0000000..bf41037 --- /dev/null +++ b/modules/soporte/docs/operacion/20-configuraciones-criticas.md @@ -0,0 +1,88 @@ +# Configuraciones críticas + +Dónde vive cada cosa que se configura. La pregunta que más tiempo hace perder es *«¿esto dónde se cambia?»*, sobre todo porque no todo está en la base de datos. + +## Las tres tablas de configuración + +| Tabla | Contenido | Se edita desde | +|---|---|---| +| `system_config` | Credenciales de WhatsApp, webhook, mensaje de términos | Base de datos | +| `lab_config` | Datos de la empresa, encabezados de documentos, clave y consumo de LIA | Configuración del laboratorio | +| `turnero_*` | Lugares, prioridades, dispositivos, playlist de TV | Configuración del turnero | + +## Lo que NO está en la base de datos + +Esto es lo que más confunde: + +| Configuración | Dónde vive de verdad | +|---|---| +| URL del botón de consentimiento | **Plantilla en el WhatsApp Manager de Meta** | +| Texto y formato de las plantillas | **Meta** (`message_templates` es solo una copia) | +| Dominio del sistema | Se deduce del `HTTP_HOST` de cada petición | + +> Cambiar el código **no** cambia la URL que reciben los pacientes en el botón de una plantilla. Eso se edita en Meta y requiere reaprobación. + +## Datos de la empresa + +En `lab_config`, salen impresos en el encabezado de todos los documentos: + +| Clave | Ejemplo | +|---|---| +| `empresa_nombre` | XIMENA CAICEDO G. E.U | +| `empresa_subtitulo` | Laboratorio Hematológico | +| `empresa_direccion` | Calle 21 #0A-26, Barrio Blanco | +| `empresa_ciudad` | Cúcuta, Norte de Santander | +| `empresa_telefono` | +57 305 337 0116 | +| `empresa_email` | servicioalcliente@laboratorioximenacaicedo.com | +| `doc_logo_base64` | Logo embebido | +| `doc_color` | Color de encabezados | + +Se editan desde **Configuración del laboratorio**, sin tocar código. + +## Dominio del sistema + +`APP_URL` y `BASE_URL` se calculan en cada petición a partir del host (`config/config.php`): + +```php +$__host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +define('APP_URL', $__proto . '://' . $__host); +``` + +Detecta HTTPS detrás de proxy reverso mediante `X-Forwarded-Proto`. + +> Consecuencia: si alguien entra por una IP o un dominio alternativo, **los enlaces que se generen en esa sesión llevarán esa dirección** — y quedan guardados así en el mensaje que recibe el paciente. Si eso importa, conviene fijar `APP_URL` explícitamente. + +## Turnero + +| Qué | Dónde | +|---|---| +| Escritorios y estaciones | `turnero_lugares` | +| Formularios obligatorios por estación | `turnero_lugar_consentimientos` | +| Formularios obligatorios por examen | `exam_tipo_consentimientos` | +| Equipos fijos por IP o token | `turnero_dispositivos` | +| Prioridades de la cola | `turnero_prioridades` | +| Playlist de la pantalla de TV | `turnero_tv_media` | + +Las estaciones de toma de muestras exigen el formulario **Datos Toma de Muestras (F-LAB-08)**, incluso en visitas marcadas como *solo entrega de muestras*. + +## LIA + +| Clave | Qué es | +|---|---| +| `lab_config.gemini_api_key` | Clave de la API de Google Gemini | +| `lab_config.lia_tokens_usados` | Consumo acumulado (tope: 1.000.000) | + +El tope está en el código como `LIA_TOKENS_MAX`. + +## Términos y condiciones + +En **dos** lugares que hay que mantener sincronizados: + +- `terms_versions` — versión activa, URL del documento, mensajes de aceptación y rechazo +- `system_config.terms_message` — el texto de bienvenida, que **repite la URL** dentro + +## Cambios de esquema + +Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`). + +Si aplicás un cambio directo en producción, **dejá también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle. diff --git a/modules/soporte/module.php b/modules/soporte/module.php new file mode 100644 index 0000000..582a073 --- /dev/null +++ b/modules/soporte/module.php @@ -0,0 +1,36 @@ + 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'], + ['name' => 'Manual de usuario', 'icon' => 'fas fa-book-reader', + 'route' => '/erp.php?m=soporte&v=documentacion&s=manual'], +]; + +if ($_sopEsAdmin) { + $_sopLinks[] = ['name' => 'Documentación técnica', 'icon' => 'fas fa-code', + 'route' => '/erp.php?m=soporte&v=documentacion&s=tecnica']; + $_sopLinks[] = ['name' => 'Arquitectura', 'icon' => 'fas fa-sitemap', + 'route' => '/erp.php?m=soporte&v=documentacion&s=arquitectura']; + $_sopLinks[] = ['name' => 'Operación y soporte', 'icon' => 'fas fa-life-ring', + 'route' => '/erp.php?m=soporte&v=documentacion&s=operacion']; +} + +return [ + 'slug' => 'soporte', + 'name' => 'Soporte', + 'icon' => 'fas fa-life-ring', + 'category' => 'sistema', + 'route' => '/erp.php?m=soporte&v=documentacion', + 'is_active' => true, + 'sort_order' => 90, + 'oleada' => 1, + 'description' => 'Documentación del proyecto: manual de usuario, técnica, arquitectura y operación', + 'links' => $_sopLinks, +]; diff --git a/modules/soporte/views/documentacion.php b/modules/soporte/views/documentacion.php new file mode 100644 index 0000000..1972479 --- /dev/null +++ b/modules/soporte/views/documentacion.php @@ -0,0 +1,296 @@ +&d= documento puntual + * ?s= portada de la sección + * (sin parámetros) portada general + */ +require_once APP_ROOT . '/config/config.php'; +if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; } + +require_once __DIR__ . '/../Markdown.php'; +require_once __DIR__ . '/../DocIndex.php'; +require_once __DIR__ . '/../Generadores.php'; + +$arbol = DocIndex::arbol(); +$seccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? ''); +$docSlug = preg_replace('/[^a-z0-9-]/', '', $_GET['d'] ?? ''); + +$archivo = ($seccion && $docSlug) ? DocIndex::resolver($seccion, $docSlug) : null; +$titulo = 'Documentación'; +$cuerpo = ''; +$toc = []; + +if ($archivo) { + $md = Generadores::expandir((string)file_get_contents($archivo)); + $cuerpo = Markdown::render($md); + $toc = Markdown::indice($md); + foreach ($arbol[$seccion]['docs'] ?? [] as $d) { + if ($d['slug'] === $docSlug) { $titulo = $d['titulo']; break; } + } +} elseif ($seccion && isset($arbol[$seccion])) { + $titulo = $arbol[$seccion]['titulo']; +} + +Layout::open('Soporte · Documentación', 'fas fa-life-ring'); +?> + + +
    + + + +
    +
    + +
    + Documentación +  ›  + + + +  ›  +
    + + + +
    + Documentación +  ›  +
    +
    +

    +

    +
    + + + +

    Esta sección aún no tiene documentos.

    + + + +
    +

    Documentación del sistema

    +

    Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.

    +
    +
    + $cfg): ?> + + +
    +
    +
    documento
    +
    + +
    + +
    + + + + +
    +
    + + + +