From 018fb13332fce581e389b51241b8048f66f18916 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:07:06 -0500 Subject: [PATCH] =?UTF-8?q?M=C3=B3dulo=20Soporte:=20visor=20de=20documenta?= =?UTF-8?q?ci=C3=B3n=20con=20renderizado=20Markdown=20y=20buscador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones: manual de usuario (visible para todos), y documentación técnica, arquitectura y operación (solo administradores). - Markdown.php: renderizador propio del subconjunto que usa la documentación (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto antes de aplicar formato, así que los .md no pueden inyectar HTML. Se prefirió un archivo auditable a incorporar una dependencia externa. - DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección y construye el índice del buscador. - Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}}, {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para que los inventarios no puedan quedar desactualizados. Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read. Co-Authored-By: Claude Opus 5 --- config/config.php | 1 + modules/soporte/DocIndex.php | 131 ++++++++ modules/soporte/Generadores.php | 220 +++++++++++++ modules/soporte/Markdown.php | 238 ++++++++++++++ .../docs/arquitectura/10-vision-general.md | 61 ++++ .../docs/arquitectura/20-enrutamiento.md | 123 ++++++++ .../docs/arquitectura/30-roles-y-permisos.md | 113 +++++++ .../docs/arquitectura/40-modelo-de-datos.md | 85 +++++ .../soporte/docs/arquitectura/50-whatsapp.md | 85 +++++ modules/soporte/docs/operacion/10-runbook.md | 167 ++++++++++ .../operacion/20-configuraciones-criticas.md | 88 ++++++ modules/soporte/module.php | 36 +++ modules/soporte/views/documentacion.php | 296 ++++++++++++++++++ 13 files changed, 1644 insertions(+) create mode 100644 modules/soporte/DocIndex.php create mode 100644 modules/soporte/Generadores.php create mode 100644 modules/soporte/Markdown.php create mode 100644 modules/soporte/docs/arquitectura/10-vision-general.md create mode 100644 modules/soporte/docs/arquitectura/20-enrutamiento.md create mode 100644 modules/soporte/docs/arquitectura/30-roles-y-permisos.md create mode 100644 modules/soporte/docs/arquitectura/40-modelo-de-datos.md create mode 100644 modules/soporte/docs/arquitectura/50-whatsapp.md create mode 100644 modules/soporte/docs/operacion/10-runbook.md create mode 100644 modules/soporte/docs/operacion/20-configuraciones-criticas.md create mode 100644 modules/soporte/module.php create mode 100644 modules/soporte/views/documentacion.php 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
    +
    + +
    + +
    + + + + +
    +
    + + + +