Compare commits
2
Commits
8948ea67d1
...
32c209710c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32c209710c | ||
|
|
018fb13332 |
@@ -0,0 +1,45 @@
|
||||
# Documentación del proyecto
|
||||
|
||||
La documentación vive **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
Se escribe en Markdown, en `modules/soporte/docs/`, y se versiona con el código.
|
||||
|
||||
| Sección | Carpeta | Quién la ve |
|
||||
|---|---|---|
|
||||
| Manual de usuario | `docs/manual/` | Cualquier usuario autenticado |
|
||||
| Documentación técnica | `docs/tecnica/` | Administradores |
|
||||
| Arquitectura | `docs/arquitectura/` | Administradores |
|
||||
| Operación y soporte | `docs/operacion/` | Administradores |
|
||||
|
||||
## Agregar o editar una página
|
||||
|
||||
Creá un `.md` en la carpeta de la sección. El nombre lleva un prefijo numérico
|
||||
que solo sirve para ordenar:
|
||||
|
||||
modules/soporte/docs/tecnica/70-mi-tema.md
|
||||
|
||||
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
|
||||
en ningún índice: se descubre solo.
|
||||
|
||||
## Contenido que se genera solo
|
||||
|
||||
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
|
||||
datos leídos del código y de la base:
|
||||
|
||||
| Marcador | Qué inserta |
|
||||
|----------|-------------|
|
||||
| `{{modulos}}` | Módulos, con sus vistas y endpoints |
|
||||
| `{{endpoints}}` | Todos los endpoints por módulo |
|
||||
| `{{tablas}}` | Tablas de la base, agrupadas por prefijo |
|
||||
| `{{roles}}` | Roles, usuarios activos y sus permisos |
|
||||
| `{{servicios}}` | Clases de `core/`, `services/` y `classes/` |
|
||||
|
||||
Así los inventarios no pueden quedar desactualizados. La descripción de cada
|
||||
endpoint sale de su comentario de cabecera: si lo escribís bien, aparece bien.
|
||||
|
||||
## Documentos anteriores
|
||||
|
||||
`DOCUMENTACION_LAB.md` y `README_LAB.md` quedaron de una etapa previa y están
|
||||
desactualizados. `WEBHOOK_ENDPOINTS.md` se migró a la sección técnica.
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/DocIndex.php
|
||||
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
|
||||
* control de acceso por sección.
|
||||
*
|
||||
* Convenciones:
|
||||
* docs/<seccion>/<NN>-<slug>.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Generadores.php
|
||||
* Secciones de la documentación que se leen del código y de la base de datos
|
||||
* en cada carga, en vez de escribirse a mano.
|
||||
*
|
||||
* El objetivo es que los inventarios (módulos, endpoints, tablas, roles) no
|
||||
* puedan quedar desactualizados: si alguien agrega un módulo o una tabla,
|
||||
* aparece aquí sin que nadie tenga que acordarse de editar un .md.
|
||||
*
|
||||
* Se invocan desde los documentos con marcadores en una línea propia:
|
||||
* {{modulos}} {{endpoints}} {{tablas}} {{roles}} {{servicios}}
|
||||
*/
|
||||
|
||||
final class Generadores
|
||||
{
|
||||
/** Reemplaza los marcadores {{...}} de un documento por su tabla generada. */
|
||||
public static function expandir(string $md): string
|
||||
{
|
||||
return preg_replace_callback('/^\{\{(\w+)\}\}\s*$/m', function ($m) {
|
||||
$metodo = 'gen' . ucfirst($m[1]);
|
||||
if (!method_exists(self::class, $metodo)) return $m[0];
|
||||
try {
|
||||
return self::$metodo();
|
||||
} catch (\Throwable $e) {
|
||||
return '> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Markdown.php
|
||||
* Renderizador Markdown → HTML para la documentación del proyecto.
|
||||
*
|
||||
* Cubre el subconjunto que usa la documentación: encabezados, listas
|
||||
* (anidadas y numeradas), tablas, bloques de código, citas, reglas, enlaces,
|
||||
* énfasis y código en línea. No pretende ser CommonMark completo — se prefirió
|
||||
* un archivo propio y auditable a incorporar una dependencia externa.
|
||||
*
|
||||
* Todo el texto se escapa antes de aplicar formato, así que el contenido de
|
||||
* los .md no puede inyectar HTML.
|
||||
*/
|
||||
|
||||
final class Markdown
|
||||
{
|
||||
/** Convierte un documento Markdown completo a HTML. */
|
||||
public static function render(string $texto): string
|
||||
{
|
||||
$lineas = preg_split('/\R/', $texto);
|
||||
$html = '';
|
||||
$n = count($lineas);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $n) {
|
||||
$linea = $lineas[$i];
|
||||
|
||||
// ── Bloque de código cercado ──────────────────────────────
|
||||
if (preg_match('/^```\s*([\w-]*)\s*$/', $linea, $m)) {
|
||||
$lang = $m[1];
|
||||
$buffer = [];
|
||||
$i++;
|
||||
while ($i < $n && !preg_match('/^```\s*$/', $lineas[$i])) {
|
||||
$buffer[] = $lineas[$i];
|
||||
$i++;
|
||||
}
|
||||
$i++; // cerrar
|
||||
$clase = $lang ? ' class="lang-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
|
||||
$html .= '<pre><code' . $clase . '>'
|
||||
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
|
||||
. '</code></pre>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Línea en blanco ───────────────────────────────────────
|
||||
if (trim($linea) === '') { $i++; continue; }
|
||||
|
||||
// ── Regla horizontal ──────────────────────────────────────
|
||||
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
|
||||
$html .= '<hr>';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Encabezado ────────────────────────────────────────────
|
||||
if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) {
|
||||
$nivel = strlen($m[1]);
|
||||
$texto2 = trim($m[2]);
|
||||
$slug = self::slug($texto2);
|
||||
$html .= "<h{$nivel} id=\"{$slug}\">" . self::inline($texto2) . "</h{$nivel}>";
|
||||
$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 .= '<blockquote>' . self::render(implode("\n", $buffer)) . '</blockquote>';
|
||||
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 .= '<p>' . self::inline(implode(' ', $buffer)) . '</p>';
|
||||
}
|
||||
|
||||
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 . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$html .= '<li>' . self::inline($m[3]) . '</li>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . "</{$tag}>", $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 = '<div class="tabla-scroll"><table><thead><tr>';
|
||||
foreach ($encabezado as $k => $c) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<th style="text-align:' . $a . '">' . self::inline($c) . '</th>';
|
||||
}
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) {
|
||||
$fila = $celdas($lineas[$i]);
|
||||
$html .= '<tr>';
|
||||
foreach ($encabezado as $k => $_) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<td style="text-align:' . $a . '">' . self::inline($fila[$k] ?? '') . '</td>';
|
||||
}
|
||||
$html .= '</tr>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . '</tbody></table></div>', $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[] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES) . '</code>';
|
||||
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 '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '"' . $ext . '>' . $m[1] . '</a>';
|
||||
},
|
||||
$texto
|
||||
);
|
||||
|
||||
$texto = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $texto);
|
||||
$texto = preg_replace('/(?<![\w*])\*([^*\n]+)\*(?![\w*])/', '<em>$1</em>', $texto);
|
||||
$texto = preg_replace('/(?<![\w_])_([^_\n]+)_(?![\w_])/', '<em>$1</em>', $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;
|
||||
}
|
||||
}
|
||||
@@ -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/<slug>/views/<vista>.php la pantalla concreta
|
||||
└── modules/<slug>/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
|
||||
@@ -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
|
||||
<?php return [
|
||||
'slug' => '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 `<head>`, 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.
|
||||
@@ -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');
|
||||
...
|
||||
<?php if ($puedeEscribir): ?><button>Nuevo domicilio</button><?php endif; ?>
|
||||
```
|
||||
|
||||
## 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 = <role_id>;
|
||||
```
|
||||
|
||||
Si los datos se ven bien y el usuario sigue sin acceso: **no ha vuelto a iniciar sesión**.
|
||||
@@ -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}}
|
||||
@@ -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=<UUID>`, pero el consentimiento del turnero lo muestra `ver_formulario_enviado.php?token=<UUID>`.
|
||||
|
||||
`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 |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Decisiones y deuda técnica
|
||||
|
||||
Por qué el sistema es como es, y qué cosas conviene saber antes de tocarlo.
|
||||
|
||||
## Decisiones tomadas a propósito
|
||||
|
||||
### Migración gradual, sin corte
|
||||
|
||||
Conviven el sistema original (archivos en la raíz) y el nuevo (módulos). No hubo una reescritura de golpe.
|
||||
|
||||
**Por qué:** el laboratorio opera todos los días. Una reescritura completa implicaba congelar el desarrollo o mantener dos sistemas en paralelo.
|
||||
|
||||
**Costo:** hay que saber en cuál de los dos está el código de cada pantalla. Los `lab_*` suelen estar en la raíz; el turnero está en el módulo.
|
||||
|
||||
### Sin framework de frontend
|
||||
|
||||
Las vistas son PHP que emiten HTML, con JavaScript embebido en la misma vista.
|
||||
|
||||
**Por qué:** despliegue por `git pull`, sin build ni compilación. Un archivo se edita y ya está en producción.
|
||||
|
||||
**Costo:** hay código repetido entre vistas, y las vistas grandes (el turnero) pasan de las 2.000 líneas.
|
||||
|
||||
### Esquemas de formulario en JSON
|
||||
|
||||
Los formularios se definen en JSON dentro de `lab_formularios.esquema`, no en tablas normalizadas.
|
||||
|
||||
**Por qué:** las fichas clínicas cambian seguido y cada una tiene campos distintos. Normalizarlas obligaba a migrar el esquema con cada formulario nuevo.
|
||||
|
||||
**Costo:** no se puede consultar por SQL «todos los pacientes con fiebre». Las respuestas viven dentro de un JSON.
|
||||
|
||||
### La identidad de quien firma se resuelve en el servidor
|
||||
|
||||
Nunca se acepta del navegador quién firmó algo.
|
||||
|
||||
**Por qué:** es un dato con valor legal. Un cliente puede mentir; la sesión no.
|
||||
|
||||
### El turno original nunca se modifica
|
||||
|
||||
Cuando una muestra pendiente se completa en una visita posterior, el turno original **queda como estaba**. Solo se registra el vínculo.
|
||||
|
||||
**Por qué:** el turno cerrado es un registro histórico. Alterarlo retroactivamente falsea los tiempos de atención y la facturación de aquel día.
|
||||
|
||||
### Inventarios generados, no escritos
|
||||
|
||||
Las tablas de módulos, endpoints, tablas y roles de esta documentación se leen del código y la base en cada carga.
|
||||
|
||||
**Por qué:** una lista escrita a mano envejece sin que nadie se entere. Una generada no puede mentir.
|
||||
|
||||
## Deuda técnica conocida
|
||||
|
||||
### Columnas `can_*` que no hacen nada
|
||||
|
||||
`role_modules` tiene `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` — y **el control de acceso no las lee**. Solo usa `permission` (`read`/`write`).
|
||||
|
||||
**Riesgo:** poner `can_edit = 0` da falsa sensación de haber restringido algo. Ya causó confusión.
|
||||
|
||||
**Arreglo:** o se usan de verdad, o se eliminan. Mientras tanto, conviene mantenerlas coherentes con `permission`.
|
||||
|
||||
### `role` y `role_id` duplicados
|
||||
|
||||
Un usuario tiene el rol en dos columnas. La interfaz lee una, los permisos salen de la otra.
|
||||
|
||||
**Riesgo:** cambiar solo `role` deja al usuario con permisos que no corresponden.
|
||||
|
||||
**Arreglo:** derivar `role` de `role_id` en lugar de almacenarlo.
|
||||
|
||||
### Dos vías para el mismo formulario
|
||||
|
||||
`turnero_consentimientos` y `lab_form_envios` guardan lo mismo con estructuras distintas y tokens de formato distinto. `ver_formulario_enviado.php` tiene que manejar ambos, y `form_cliente.php` existe solo para redirigir entre ellos.
|
||||
|
||||
**Por qué sigue así:** unificarlas obliga a cambiar la plantilla aprobada en Meta y migrar los registros históricos.
|
||||
|
||||
### Convenciones de nombre mezcladas
|
||||
|
||||
Conviven `creado_at` y `created_at`, `creado_por` y `enviado_por`, español e inglés. Depende de la época de cada tabla.
|
||||
|
||||
### Vistas muy grandes
|
||||
|
||||
`ver_formulario_enviado.php` supera las 3.000 líneas y mezcla render, lógica de tomas prolongadas y JavaScript. Es el archivo más delicado de tocar del sistema.
|
||||
|
||||
### Datos históricos incompletos
|
||||
|
||||
Algunas columnas se agregaron después y las filas viejas quedaron en `NULL`, sin forma de recuperarlas:
|
||||
|
||||
| Columna | Desde | Antes |
|
||||
|---|---|---|
|
||||
| `turnero_consentimientos.creado_por` | 3 ago 2026 | `NULL` |
|
||||
| Identidad por toma en F-LAB-28 | 3 ago 2026 | No se guardaba |
|
||||
| `admin_users.cedula` | 3 ago 2026 | Solo enfermeros la tenían |
|
||||
|
||||
No hay traza de auditoría que permita reconstruirlos.
|
||||
|
||||
### El dominio se deduce de cada petición
|
||||
|
||||
`APP_URL` sale del `HTTP_HOST`. Si alguien entra por una IP o un dominio alterno, los enlaces que se generen llevarán esa dirección — y quedan guardados así en el WhatsApp del paciente.
|
||||
|
||||
**Arreglo:** fijar `APP_URL` explícitamente.
|
||||
|
||||
## Al hacer cambios
|
||||
|
||||
- **Cambio de esquema** → dejá la migración en `migrations/`, idempotente.
|
||||
- **Endpoint nuevo** → verificá permisos ahí adentro, no confíes en la vista.
|
||||
- **Tocar el turnero** → es lo que más gente usa a diario; probá con un turno real.
|
||||
- **Tocar formularios firmados** → tienen valor legal. Un render roto es un documento inválido.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Primeros pasos
|
||||
|
||||
Lo mínimo para moverse por el sistema, sin importar el rol.
|
||||
|
||||
## Entrar
|
||||
|
||||
Se ingresa con usuario y contraseña. Si no reconocés tu usuario, buscá tu **número de cédula**: la mayoría de las cuentas del personal se crearon así.
|
||||
|
||||
Al entrar vas directo a la pantalla principal de tu rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para vos.
|
||||
|
||||
## Si no ves algo que deberías ver
|
||||
|
||||
Casi siempre es una de estas dos:
|
||||
|
||||
1. **Te cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cerrá sesión y volvé a entrar.
|
||||
2. **Tu rol no lo incluye.** Pedile a un administrador que lo revise.
|
||||
|
||||
## Cómo está organizado
|
||||
|
||||
| Zona | Qué contiene |
|
||||
|---|---|
|
||||
| Menú izquierdo | Los módulos a los que tenés acceso |
|
||||
| Barra superior | Tu usuario y el cierre de sesión |
|
||||
| Centro | La pantalla activa |
|
||||
|
||||
## Los módulos principales
|
||||
|
||||
| Módulo | Para qué |
|
||||
|---|---|
|
||||
| **Turnero** | Turnos presenciales: recepción, toma de muestras, pantallas |
|
||||
| **Domicilios** | Visitas domiciliarias y su asignación a enfermeros |
|
||||
| **Pacientes** | Fichas clínicas e historial |
|
||||
| **Órdenes médicas** | Órdenes recibidas |
|
||||
| **Formularios** | Consentimientos y fichas; diseño y envíos |
|
||||
| **Soporte** | Esta documentación |
|
||||
|
||||
## Cosas que conviene saber desde el principio
|
||||
|
||||
**Los turnos no se borran.** Se cancelan o se marcan como ausente, pero quedan registrados. Es a propósito: el historial tiene valor clínico y administrativo.
|
||||
|
||||
**Las firmas quedan con nombre y cédula.** Cuando firmás un formulario, el sistema registra quién sos. No es opcional ni configurable.
|
||||
|
||||
**Una muestra pendiente no se pierde.** Si un paciente queda debiendo una muestra y vuelve otro día, aparece sola en la estación, marcada como *visita anterior*, con los exámenes de aquella orden.
|
||||
|
||||
**Nadie factura lo que no terminó.** En los reportes del día, lo cobrado en turnos finalizados y lo que sigue en curso se muestran por separado. Los ausentes y cancelados no se cuentan.
|
||||
|
||||
## Manual según tu rol
|
||||
|
||||
- [Recepción](?m=soporte&v=documentacion&s=manual&d=recepcion)
|
||||
- [Toma de muestras](?m=soporte&v=documentacion&s=manual&d=toma-de-muestras)
|
||||
- [Enfermeros — domicilios](?m=soporte&v=documentacion&s=manual&d=enfermeros)
|
||||
- [Administración](?m=soporte&v=documentacion&s=manual&d=administracion)
|
||||
@@ -0,0 +1,78 @@
|
||||
# Recepción
|
||||
|
||||
Guía de la pantalla de recepción del turnero: desde que llamás al paciente hasta que pasa a toma de muestras.
|
||||
|
||||
## Tu escritorio
|
||||
|
||||
Si el equipo está registrado por IP o token, el menú te muestra **solo tu escritorio**. Si no lo está, ves todos y elegís.
|
||||
|
||||
Esto lo configura un administrador en `Configuración del turnero`. Si estás viendo escritorios que no son el tuyo, avisá: significa que ese equipo no quedó registrado.
|
||||
|
||||
## El flujo completo
|
||||
|
||||
### 1. Llamar al paciente
|
||||
|
||||
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También podés llamar a uno específico si hace falta saltarse el orden.
|
||||
|
||||
El turno aparece en la pantalla de TV de la sala de espera.
|
||||
|
||||
Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y no cuenta en la facturación.
|
||||
|
||||
### 2. Verificar o crear el paciente
|
||||
|
||||
Buscá por cédula o nombre.
|
||||
|
||||
- **Existe** → se cargan sus datos y su historial.
|
||||
- **No existe** → creá la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
|
||||
|
||||
> El teléfono importa más de lo que parece: es a donde se envían los consentimientos y las encuestas. Un número mal escrito significa un consentimiento que nunca llega.
|
||||
|
||||
### 3. Seleccionar exámenes
|
||||
|
||||
Cargá los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
|
||||
|
||||
Al elegir los exámenes, el sistema decide solo qué consentimientos hacen falta: algunos van atados a un examen concreto (VIH, por ejemplo) y otros a la estación de destino.
|
||||
|
||||
### 4. Datos de facturación
|
||||
|
||||
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargá el NIT y la autorización.
|
||||
|
||||
Se admite pago combinado — efectivo más tarjeta, por ejemplo.
|
||||
|
||||
### 5. Consentimientos
|
||||
|
||||
Los que hagan falta aparecen listados con su estado. Se envían al WhatsApp del paciente, que los firma desde el celular.
|
||||
|
||||
**No podés guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
|
||||
|
||||
Si el envío por WhatsApp falla, el sistema manda un enlace en texto plano como respaldo.
|
||||
|
||||
### 6. Elegir destino y guardar
|
||||
|
||||
Seleccioná la estación de toma de muestras y guardá. El turno pasa a esa estación y el paciente sale de tu escritorio.
|
||||
|
||||
## Situaciones frecuentes
|
||||
|
||||
### El paciente solo viene a entregar una muestra
|
||||
|
||||
Marcá **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
|
||||
|
||||
> Ojo: el formulario **Datos Toma de Muestras (F-LAB-08)** se sigue exigiendo en la estación. Ese no se omite nunca, porque recoge la historia clínica del momento de la toma.
|
||||
|
||||
### El paciente ya vino antes y quedó debiendo una muestra
|
||||
|
||||
No tenés que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
|
||||
|
||||
### El paciente no recibió el consentimiento
|
||||
|
||||
1. Verificá el número de teléfono en su ficha.
|
||||
2. Reenvialo desde la lista de consentimientos.
|
||||
3. Si sigue sin llegar, avisá a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
|
||||
|
||||
### Hay que corregir algo después de guardar
|
||||
|
||||
Mientras el turno no esté finalizado, un administrador puede reabrirlo desde el historial y cambiar su estado.
|
||||
|
||||
## Encuestas
|
||||
|
||||
Desde el historial se le puede enviar una encuesta de satisfacción al paciente por WhatsApp.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Toma de muestras
|
||||
|
||||
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
|
||||
|
||||
## Tu estación
|
||||
|
||||
Igual que en recepción, si el equipo está registrado por IP o token, ves **solo tu estación**. Si no, las ves todas.
|
||||
|
||||
## Atender un turno
|
||||
|
||||
Los pacientes derivados desde recepción aparecen en tu bandeja. Al abrir uno ves su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
|
||||
|
||||
## Recibir muestras
|
||||
|
||||
Cada muestra tiene tres estados posibles:
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| **Recibida** | La tomaste o el paciente la entregó correctamente |
|
||||
| **Pendiente** | No se pudo obtener; queda debiendo |
|
||||
| **Rechazada** | Se obtuvo pero no sirve — hemólisis, volumen insuficiente, mal rotulada |
|
||||
|
||||
Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve después en el historial.
|
||||
|
||||
### Muestras de visitas anteriores
|
||||
|
||||
Si el paciente quedó debiendo una muestra otro día, te aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepas de qué se trataba.
|
||||
|
||||
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial podés saltar de uno al otro.
|
||||
|
||||
> **El turno original no se modifica.** Sigue finalizado como estaba. Solo se registra en qué visita se completó la muestra.
|
||||
|
||||
## Formularios
|
||||
|
||||
### Datos Toma de Muestras (F-LAB-08)
|
||||
|
||||
Obligatorio en todas las estaciones. Recoge la historia clínica del momento: síntomas, antecedentes familiares y personales, medicación, datos obstétricos si corresponde.
|
||||
|
||||
Lo firmás vos, no el paciente.
|
||||
|
||||
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la ponés vos, con la fecha de hoy.
|
||||
|
||||
### Control de Tomas de Muestras Prolongadas (F-LAB-28)
|
||||
|
||||
Para exámenes que requieren varias tomas en el tiempo: curvas de glicemia, prolactina, cortisol, test de Sullivan.
|
||||
|
||||
Cómo funciona:
|
||||
|
||||
1. **Marcá el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
|
||||
2. **Configurá los tiempos** si te lo pide (minuto 0, 30, 60…).
|
||||
3. **Firmá cada toma** a medida que la hacés. El sistema registra la hora y **quién firmó**.
|
||||
4. Cuando firmás una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
|
||||
|
||||
> Cada toma se firma por separado y queda con el nombre de quien la hizo. Si cambia el turno del personal a mitad del protocolo, cada toma conserva el nombre correcto.
|
||||
|
||||
Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer indicando el motivo.
|
||||
|
||||
## Antes de finalizar
|
||||
|
||||
El sistema no te deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
|
||||
|
||||
## Comentarios
|
||||
|
||||
Podés dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**¿Puedo revertir una muestra que marqué mal?**
|
||||
Sí. Con el botón de deshacer vuelve a pendiente.
|
||||
|
||||
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
|
||||
Avisá a soporte. Debería mostrar únicamente las del examen marcado.
|
||||
|
||||
**¿Qué pasa si el paciente se va sin dar una muestra?**
|
||||
Dejala en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Guía del portal del enfermero: tus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
|
||||
|
||||
## Tu portal
|
||||
|
||||
Al entrar vas directo al portal. Ves **tus** domicilios: los que te asignaron y los que agendaste vos.
|
||||
|
||||
Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
## Agendar un domicilio
|
||||
|
||||
**Nuevo domicilio** abre el formulario:
|
||||
|
||||
1. **Paciente** — buscalo por cédula. Si no existe, se crea ahí mismo.
|
||||
2. **Dirección** — la del paciente con un botón, o escribí otra. Agregá indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización** si aplica.
|
||||
6. **Valores** — domicilio, copago.
|
||||
|
||||
## Editar
|
||||
|
||||
Podés editar los domicilios **que agendaste vos** y también **los que te asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
|
||||
|
||||
## Avisar al paciente por WhatsApp
|
||||
|
||||
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisás y enviás.
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Podés dejar dos tipos de nota en cada domicilio:
|
||||
|
||||
- **Nota de ficha** — estructurada, para datos clínicos.
|
||||
- **Nota libre** — texto suelto.
|
||||
|
||||
Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o resultados.
|
||||
|
||||
## Formularios
|
||||
|
||||
Podés enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
|
||||
|
||||
Cuando firmás vos un formulario, queda registrado con tu **nombre y cédula**, tomados de tu ficha de enfermero.
|
||||
|
||||
## Estados de un domicilio
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| Programado | Agendado, sin atender |
|
||||
| En curso | Estás en la visita |
|
||||
| Completado | Terminado |
|
||||
| Cancelado | No se hizo |
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**No puedo editar un domicilio.**
|
||||
Solo se pueden editar los propios o los asignados a vos, y solo si no está completado ni cancelado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Editá el domicilio. Si el cambio es permanente, actualizá también la ficha del paciente.
|
||||
|
||||
**Necesito reprogramar.**
|
||||
Editá la fecha y hora, y avisale al paciente por WhatsApp.
|
||||
|
||||
**¿Puedo ver domicilios de otro enfermero?**
|
||||
No. El portal muestra únicamente los tuyos.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Administración
|
||||
|
||||
Tareas de administrador: usuarios, permisos, configuración y reportes.
|
||||
|
||||
## Usuarios y permisos
|
||||
|
||||
### Crear un usuario
|
||||
|
||||
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
|
||||
|
||||
Cargá también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
|
||||
|
||||
Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no del usuario.
|
||||
|
||||
### Cambiar el rol de alguien
|
||||
|
||||
> Un usuario tiene **dos** campos de rol y hay que cambiar los dos. El texto (`role`) es lo que muestra la interfaz; el vínculo (`role_id`) es de donde salen los permisos reales.
|
||||
|
||||
Si cambiás solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
|
||||
|
||||
Después del cambio, **el usuario debe cerrar sesión y volver a entrar**: los permisos se cargan al iniciar sesión, no en cada pantalla.
|
||||
|
||||
### Dejar un módulo en solo lectura
|
||||
|
||||
Lo que decide si alguien puede modificar es el campo `permission` (`read` o `write`) de cada módulo del rol.
|
||||
|
||||
> Las columnas `can_editar`, `can_crear` y similares **no se usan** para el control de acceso. Ponerlas en cero no impide nada. Lo que manda es `permission`.
|
||||
|
||||
## Turnero
|
||||
|
||||
### Escritorios y estaciones
|
||||
|
||||
Cada puesto de recepción y cada estación de muestras es un *lugar*. Se les puede asignar:
|
||||
|
||||
- **Formularios obligatorios** — todo paciente que pase por ahí los debe firmar.
|
||||
- **Equipos por IP o token** — así el operador ve solo su puesto y no puede confundirse.
|
||||
|
||||
### Formularios obligatorios
|
||||
|
||||
Se exigen por dos vías, y se acumulan:
|
||||
|
||||
| Vía | Ejemplo |
|
||||
|---|---|
|
||||
| Por examen | VIH exige su consentimiento específico |
|
||||
| Por estación | Toda toma de muestras exige F-LAB-08 |
|
||||
|
||||
### Pantalla de TV
|
||||
|
||||
Admite una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro. Se pueden reordenar arrastrando y a cada imagen se le fija cuántos segundos dura.
|
||||
|
||||
### Reabrir un turno
|
||||
|
||||
Desde el historial se puede cambiar el estado de un turno, incluso reabrir uno finalizado, ausente o cancelado.
|
||||
|
||||
## Facturación del día
|
||||
|
||||
El panel muestra tres cifras:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Los turnos **ausentes y cancelados no se cuentan** en ninguna: no se van a cobrar.
|
||||
|
||||
## LIA
|
||||
|
||||
El asistente del dashboard del turnero responde preguntas sobre la operación del día: tiempos por profesional, facturación, exámenes más pedidos, buscar un paciente.
|
||||
|
||||
Tiene un presupuesto de consumo. Cuando se agota, se bloquea y hay que reponerlo. El consumo por pregunta es alto porque envía el contexto completo del día cada vez.
|
||||
|
||||
## Documentos
|
||||
|
||||
Los datos que salen en el encabezado de todos los documentos —nombre, dirección, ciudad, teléfono, logo, color— se editan desde **Configuración del laboratorio**, sin tocar código.
|
||||
|
||||
> La dirección física se cambia desde ahí. Pero **la URL de los botones que llegan por WhatsApp no**: esa vive en la plantilla aprobada por Meta y se cambia en el WhatsApp Manager, con reaprobación de por medio.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
El bot exige aceptarlos antes de conversar. La URL del documento está en **dos lugares** que hay que cambiar juntos: la versión activa de términos y el texto del mensaje de bienvenida, que la repite dentro.
|
||||
|
||||
Se vuelve a pedir la aceptación cuando pasan 6 meses o cuando se publica una versión nueva marcada para reenvío.
|
||||
|
||||
## Cuando algo falla
|
||||
|
||||
El [runbook de incidentes](?m=soporte&v=documentacion&s=operacion&d=runbook) tiene los casos frecuentes con su diagnóstico y solución.
|
||||
@@ -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 = <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 = <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" "<la URL>"
|
||||
```
|
||||
|
||||
**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 = <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;
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Despliegue y mantenimiento
|
||||
|
||||
## Cómo se despliega
|
||||
|
||||
No hay build ni compilación. El código PHP se sirve directo:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
Con eso los cambios están en producción. Es la contrapartida de no usar framework de frontend.
|
||||
|
||||
**Si el cambio incluye esquema de base de datos**, hay que correr la migración además del `git pull`.
|
||||
|
||||
## Repositorio
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Remoto | `gitea` |
|
||||
| Rama | `main` |
|
||||
|
||||
Se trabaja directo sobre `main`.
|
||||
|
||||
## Migraciones
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`.
|
||||
|
||||
**Deben ser idempotentes** — poder correrse más de una vez sin causar daño:
|
||||
|
||||
```sql
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
|
||||
|
||||
UPDATE admin_users SET cedula = username
|
||||
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$';
|
||||
```
|
||||
|
||||
MariaDB 11.8 admite `IF NOT EXISTS` en `ALTER TABLE`. Para `UPDATE` e `INSERT`, la guarda va en el `WHERE`.
|
||||
|
||||
Antes de dar por buena una migración, corrila dos veces y verificá que la segunda no cambie nada.
|
||||
|
||||
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si tocás el esquema, dejá el archivo.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
| Carpeta | Contenido |
|
||||
|---|---|
|
||||
| `uploads/turnero/tv_media/` | Videos e imágenes de la pantalla de TV |
|
||||
| `uploads/terms/` | Documentos de términos y condiciones |
|
||||
|
||||
Se crean solas al primer uso. **No están en el repositorio**: al mover el sistema de servidor hay que copiarlas aparte, o los enlaces quedan rotos.
|
||||
|
||||
## Verificaciones después de desplegar
|
||||
|
||||
```bash
|
||||
# Sintaxis de los archivos tocados
|
||||
php -l archivo.php
|
||||
|
||||
# ¿Responde un enlace público?
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
|
||||
```
|
||||
|
||||
Si el cambio afectó permisos, recordá que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
|
||||
|
||||
## Configuración por entorno
|
||||
|
||||
Las credenciales se leen de variables de entorno (`.env`, vía phpdotenv) y de `system_config`. El dominio no se configura: se deduce del `HTTP_HOST` de cada petición.
|
||||
|
||||
## Mantenimiento periódico
|
||||
|
||||
| Cada | Revisar |
|
||||
|---|---|
|
||||
| Semana | Consumo de LIA (`lab_config.lia_tokens_usados`) contra el tope de 1.000.000 |
|
||||
| Mes | Que los enlaces enviados por WhatsApp respondan — sobre todo el de términos |
|
||||
| Mes | Consentimientos que quedaron sin firmar |
|
||||
| Trimestre | Plantillas de Meta: que sigan aprobadas |
|
||||
| Trimestre | Muestras pendientes acumuladas por paciente |
|
||||
|
||||
Las consultas para varias de estas revisiones están en el [runbook](?m=soporte&v=documentacion&s=operacion&d=runbook).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Índice de módulos
|
||||
|
||||
Inventario de los módulos del sistema, generado del filesystem en cada carga.
|
||||
|
||||
{{modulos}}
|
||||
|
||||
## Cómo leer esta tabla
|
||||
|
||||
**Vistas** son las pantallas (`modules/<slug>/views/*.php`). **Endpoints** son los archivos que devuelven JSON (`modules/<slug>/api/*.php`).
|
||||
|
||||
Un módulo con **1 vista y 0 endpoints** suele ser un puente al sistema anterior: la vista solo incluye el archivo de la raíz, donde está el código real.
|
||||
|
||||
```php
|
||||
// modules/lab_domicilios/views/index.php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
**En SYSTEM_MODULES** indica si el módulo pasa por el control de permisos. Los que dicen «no» quedan accesibles para cualquier sesión — es el caso de módulos auxiliares que se consumen desde otras pantallas.
|
||||
|
||||
## Dónde está el código de verdad
|
||||
|
||||
| Módulo | Código real |
|
||||
|---|---|
|
||||
| `turnero` | En el módulo. Es el más grande y el más nuevo |
|
||||
| `registro_exams` | En el módulo |
|
||||
| `lab_examenes`, `medicos` | En el módulo |
|
||||
| `lab_domicilios`, `lab_pacientes`, `lab_ordenes`, y demás `lab_*` | Archivo de la raíz; el módulo es un puente |
|
||||
| `whatsapp` | `index.php` y `services/BotService.php` |
|
||||
| `enfermero_portal` | `enfermero_portal.php` |
|
||||
|
||||
## Servicios y clases compartidas
|
||||
|
||||
{{servicios}}
|
||||
|
||||
## Detalle por módulo
|
||||
|
||||
- [Turnero](?m=soporte&v=documentacion&s=tecnica&d=turnero)
|
||||
- [WhatsApp y bot](?m=soporte&v=documentacion&s=tecnica&d=whatsapp-bot)
|
||||
- [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios)
|
||||
- [Domicilios](?m=soporte&v=documentacion&s=tecnica&d=domicilios)
|
||||
- [Todos los endpoints](?m=soporte&v=documentacion&s=tecnica&d=endpoints)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Módulo Turnero
|
||||
|
||||
El módulo más grande del sistema: 12 vistas y unos 70 endpoints. Gestiona la atención presencial completa.
|
||||
|
||||
## Vistas
|
||||
|
||||
| Vista | Para quién | Qué hace |
|
||||
|---|---|---|
|
||||
| `dashboard` | Admin, supervisor | Métricas del día, facturación, asistente LIA |
|
||||
| `historial` | Admin, supervisor | Turnos de varios días, filtros, exportación |
|
||||
| `bandeja` | Bacteriólogo, admin | Turnos del día con su detalle completo |
|
||||
| `recepcion` | Recepcionista | Atención en el mostrador |
|
||||
| `lugar` | Bacteriólogo | Estación de toma de muestras |
|
||||
| `kiosko` | Público | El paciente saca su turno |
|
||||
| `display_global` | Público | Pantalla de TV de la sala |
|
||||
| `verificar_paciente` | Recepción | Consulta rápida de una ficha |
|
||||
| `chat` | Recepción | Conversación de WhatsApp del turnero |
|
||||
| `configuracion` | Admin | Lugares, dispositivos, plantillas, pantalla de TV |
|
||||
|
||||
`kiosko` y `display_global` son las **únicas rutas públicas** del sistema (`core/Router.php`): no hay quién inicie sesión en un televisor ni en el tótem de la entrada.
|
||||
|
||||
## Menú dinámico
|
||||
|
||||
`modules/turnero/module.php` no devuelve una lista fija: la arma según **el rol y la IP del equipo**.
|
||||
|
||||
```
|
||||
recepcionista → chat, verificar paciente, TV, y su escritorio
|
||||
bacteriólogo → bandeja y su estación
|
||||
admin → todo
|
||||
```
|
||||
|
||||
Si el equipo está en `turnero_dispositivos` (por IP o por cookie `turnero_token`), el usuario ve **solo su puesto**. Si no, los ve todos. Evita que alguien atienda desde el escritorio equivocado.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
```
|
||||
turnero_sesiones un día de operación
|
||||
└── turnero_turnos código, estado, tiempos, prioridad
|
||||
├── turnero_solicitudes qué se hace y cuánto se cobró
|
||||
│ ├── turnero_examen_items
|
||||
│ └── turnero_muestras
|
||||
├── turnero_consentimientos
|
||||
└── turnero_comentarios
|
||||
```
|
||||
|
||||
### Estados
|
||||
|
||||
```
|
||||
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
|
||||
ausente / cancelado
|
||||
```
|
||||
|
||||
Solo `finalizado` cuenta como facturación real.
|
||||
|
||||
## Consentimientos
|
||||
|
||||
Se crean automáticamente desde **dos fuentes** que se acumulan:
|
||||
|
||||
| Fuente | Tabla |
|
||||
|---|---|
|
||||
| Por examen | `exam_tipo_consentimientos` |
|
||||
| Por estación destino | `turnero_lugar_consentimientos` |
|
||||
|
||||
`get_consentimientos.php` los autocrea si faltan y calcula el estado de cada uno.
|
||||
|
||||
> En visitas de *solo entrega de muestras* no aplican los formularios por examen (no hay exámenes), pero **sí los de estación**. Por eso F-LAB-08 se exige igual.
|
||||
|
||||
## Tomas prolongadas
|
||||
|
||||
El formulario **F-LAB-28** cubre exámenes seriados. La lógica está repartida entre `ver_formulario_enviado.php` (render) y `modules/turnero/api/guardar_toma.php` (guardado).
|
||||
|
||||
- El esquema trae secciones para todos los exámenes posibles; se muestran solo las del examen marcado, mediante la `condicion` de cada separador.
|
||||
- `_tomas_config` en `datos_respuestas` guarda qué ciclos se configuraron para ese paciente.
|
||||
- Cada firma se guarda en su propio campo (`_tm00_f`, `_tm30_f`, …) junto con la hora y **la identidad de quien firmó**, resuelta en el servidor desde la sesión.
|
||||
- Al firmar, el endpoint calcula cuándo toca la siguiente toma y actualiza `siguiente_toma_at`.
|
||||
|
||||
> La identidad se resuelve **en el servidor**, no se acepta del cliente: cada toma puede firmarla un profesional distinto y esa es la única fuente confiable. Las firmas anteriores al 3 de agosto de 2026 no tienen ese dato y no es recuperable.
|
||||
|
||||
## Muestras entre visitas
|
||||
|
||||
Una muestra que queda `pendiente` o `rechazada` reaparece cuando el paciente vuelve, con la bandera `es_pendiente_anterior` y los exámenes de la orden original.
|
||||
|
||||
Al recibirla, `turnero_muestras.recibida_en_turno_id` registra en qué turno se completó. Historial y bandeja usan ese dato para enlazar ambos turnos en los dos sentidos.
|
||||
|
||||
**El turno original no se modifica**: sigue finalizado. Solo se agrega la trazabilidad.
|
||||
|
||||
## LIA
|
||||
|
||||
`api/ai_chat.php` — asistente sobre Gemini Flash.
|
||||
|
||||
| Aspecto | Detalle |
|
||||
|---|---|
|
||||
| Contexto | Se arma en cada llamada con hasta 60 turnos del día y sus detalles |
|
||||
| Historial | Los últimos intercambios se envían para que entienda preguntas de seguimiento |
|
||||
| Tope de salida | `maxOutputTokens`; si Gemini corta, se avisa con `finishReason` |
|
||||
| Presupuesto | `lab_config.lia_tokens_usados` contra `LIA_TOKENS_MAX` |
|
||||
|
||||
Se contabiliza `totalTokenCount`, que **incluye el contexto de entrada**. Como el contexto va completo en cada pregunta, el gasto por consulta es alto aunque la respuesta sea breve.
|
||||
|
||||
## Endpoints propios
|
||||
|
||||
Los del turnero usan `api/_helpers.php`, que provee `db()`, `inputJson()`, `jsonOk()`, `jsonError()`, `adminId()`, `requireTurnero()` y `notificarSSE()`.
|
||||
|
||||
`notificarSSE()` avisa a las pantallas conectadas para que se refresquen sin recargar.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Formularios y firma digital
|
||||
|
||||
Cómo se definen los formularios, cómo se envían y cómo se firman. Es transversal: lo usan el turnero, los domicilios y los envíos sueltos.
|
||||
|
||||
## Definición
|
||||
|
||||
Un formulario es una fila en `lab_formularios`. Su estructura está en la columna `esquema`, un JSON con la lista de campos:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "_sep1", "tipo": "separador", "label": "Datos del paciente"},
|
||||
{"id": "_nom", "tipo": "linked", "linked_key": "nombre_completo", "label": "Nombre"},
|
||||
{"id": "_sint", "tipo": "checkbox", "label": "Síntomas", "options": ["Fiebre", "Tos"]},
|
||||
{"id": "_fir", "tipo": "firma_profesional", "label": "Firma del profesional"}
|
||||
]
|
||||
```
|
||||
|
||||
### Tipos de campo
|
||||
|
||||
| Tipo | Qué es |
|
||||
|---|---|
|
||||
| `separador` | Encabezado de sección; admite `condicion` |
|
||||
| `parrafo` | Texto fijo (consentimientos, notas legales) |
|
||||
| `texto`, `textarea`, `numero` | Entrada libre |
|
||||
| `fecha`, `fecha_hoy`, `hora` | Fechas y horas |
|
||||
| `radio`, `checkbox`, `select` | Opciones |
|
||||
| `linked` | Se autocompleta con un dato del paciente vía `linked_key` |
|
||||
| `firma` | Firma del paciente |
|
||||
| `firma_profesional` | Firma del profesional |
|
||||
|
||||
### Secciones condicionales
|
||||
|
||||
Un separador puede depender de otro campo:
|
||||
|
||||
```json
|
||||
{"id": "_sep_ins", "tipo": "separador", "label": "Insulina · Minuto 0",
|
||||
"condicion": {"campo_id": "_examen", "valores": ["Insulina"]}}
|
||||
```
|
||||
|
||||
La sección y **todos sus campos** se ocultan si la condición no se cumple. Los campos heredan el estado mediante el atributo `data-sep-id`.
|
||||
|
||||
## Las dos vías de envío
|
||||
|
||||
Un mismo formulario se firma por dos caminos, con tablas y tokens distintos:
|
||||
|
||||
| Vía | Tabla | Token | Respuestas |
|
||||
|---|---|---|---|
|
||||
| Turnero | `turnero_consentimientos` | UUID → `?token=` | `datos_respuestas` |
|
||||
| Domicilios y envíos | `lab_form_envios` | 64 hex → `?t=` | `datos_cliente` |
|
||||
|
||||
Las dos las muestra `ver_formulario_enviado.php`, que distingue **por el formato del token**. De ahí que existan dos parámetros para lo que parece lo mismo.
|
||||
|
||||
`form_cliente.php` detecta tokens con formato UUID y redirige a `ver_formulario_enviado.php` — necesario porque la plantilla de WhatsApp aprobada en Meta apunta a la primera página.
|
||||
|
||||
## Parámetros del visor
|
||||
|
||||
| Parámetro | Efecto |
|
||||
|---|---|
|
||||
| `token` | Consentimiento del turnero (UUID) |
|
||||
| `t` | Envío de formulario (64 hex) |
|
||||
| `id` | Acceso interno con sesión |
|
||||
| `embed=1` | Modo embebido; **activa el filtrado de secciones** |
|
||||
| `compact=1` | Grilla de campos y tarjetas de toma |
|
||||
| `zoom` | Escala |
|
||||
| `autoprint=1` | Abre el diálogo de impresión |
|
||||
|
||||
> `embed=1` no es cosmético: sin él no se aplica el filtrado de secciones de tomas prolongadas y el documento muestra secciones que no corresponden. Bandeja e historial lo pasan siempre.
|
||||
|
||||
## Firma
|
||||
|
||||
### Del paciente
|
||||
|
||||
Se dibuja en un canvas y se guarda como imagen en `datos_cliente['<campo>_svg']`. También se admite pad biométrico Topaz.
|
||||
|
||||
### Del profesional
|
||||
|
||||
Se dibuja igual, pero además queda **quién firmó**. Hay tres endpoints según el contexto:
|
||||
|
||||
| Endpoint | Contexto |
|
||||
|---|---|
|
||||
| `modules/turnero/api/guardar_toma.php` | Tomas prolongadas — una firma por toma |
|
||||
| `modules/turnero/api/firmar_profesional_consentimiento.php` | Consentimientos del turnero |
|
||||
| `api/lab/firmar_profesional.php` | Envíos de formularios |
|
||||
|
||||
La identidad se guarda como `_pro_nombre` y `_pro_cedula`; en tomas prolongadas, además por campo (`_tm30_f_pro_nombre`), porque cada toma puede firmarla alguien distinto.
|
||||
|
||||
De dónde sale la identidad:
|
||||
|
||||
```
|
||||
admin_users.cedula → personal en general
|
||||
lab_enfermeras.numero_documento → enfermeros (vía admin_users.enfermera_id)
|
||||
```
|
||||
|
||||
> Al mostrar un documento firmado **no se usa un valor por defecto**: si no quedó guardado quién firmó, se muestra vacío. Antes se caía al usuario de la sesión actual, lo que atribuía la firma a quien simplemente estaba mirando el documento.
|
||||
|
||||
## Precarga desde una visita anterior
|
||||
|
||||
`modules/turnero/api/get_formulario_anterior.php` devuelve las respuestas del último formulario firmado del mismo paciente, para no reescribir la historia clínica en cada visita.
|
||||
|
||||
Excluye deliberadamente firmas e identidad del profesional anterior: cada visita se firma de nuevo, con la fecha de hoy y quien atienda.
|
||||
|
||||
## Diseñador
|
||||
|
||||
`lab_formulario_builder.php` permite armar el esquema desde la interfaz, sin escribir JSON a mano.
|
||||
@@ -0,0 +1,90 @@
|
||||
# WhatsApp y bot
|
||||
|
||||
El sistema nació como bot de WhatsApp y esa integración sigue siendo central.
|
||||
|
||||
## Servicios
|
||||
|
||||
{{servicios}}
|
||||
|
||||
## `WhatsAppService`
|
||||
|
||||
Envuelve la Cloud API de Meta. Se elige la línea al construirlo:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService(); // principal
|
||||
$wa = new WhatsAppService('turnero'); // turnero
|
||||
```
|
||||
|
||||
Ambas líneas comparten cuenta (WABA) y token; **lo único que cambia es el `phone_number_id`**. Si un mensaje sale por la línea equivocada, casi siempre falta el argumento.
|
||||
|
||||
Métodos principales:
|
||||
|
||||
```php
|
||||
$wa->sendTextMessage($telefono, $texto, $meta);
|
||||
$wa->sendTemplateMessage($telefono, $plantilla, $idioma, [], [], $componentes, $meta);
|
||||
```
|
||||
|
||||
`$meta` acompaña el registro del mensaje: `['canal' => 'turnero', 'operator_id' => adminId()]`.
|
||||
|
||||
## Plantillas
|
||||
|
||||
Fuera de la ventana de 24 horas hay que usar plantilla aprobada. `message_templates` guarda una copia local con sus `components`, pero **la copia no manda**: la versión real vive en Meta.
|
||||
|
||||
Consecuencia importante:
|
||||
|
||||
> Las URL de los botones están **en la plantilla**, no en el código. Nuestro código solo envía los parámetros (`{{1}}`). Cambiar el código no altera el enlace que recibe el paciente.
|
||||
|
||||
Ejemplo — botón de `consentimiento_turno_v2`:
|
||||
|
||||
```
|
||||
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
|
||||
```
|
||||
|
||||
Y así se arman los componentes al enviar:
|
||||
|
||||
```php
|
||||
$rawComps = [
|
||||
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]],
|
||||
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
|
||||
'parameters' => [['type' => 'text', 'text' => $token]]],
|
||||
];
|
||||
```
|
||||
|
||||
### Respaldo
|
||||
|
||||
Si el envío por plantilla falla, se manda un texto plano con el enlace armado desde el dominio del servidor. Ese texto **no** pasa por Meta, así que su URL puede diferir de la del botón.
|
||||
|
||||
## `BotService`
|
||||
|
||||
Decide qué hacer con cada mensaje entrante:
|
||||
|
||||
1. ¿Aceptó los términos? Si no, se los pide y no avanza.
|
||||
2. ¿Está en horario? (`BusinessHoursService`)
|
||||
3. ¿La conversación la tomó un operador humano? El bot no interrumpe.
|
||||
4. Si no, responde según el estado de la conversación (`ConversationStateService`) y el menú (`MenuService`).
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
| Tabla | Contenido |
|
||||
|---|---|
|
||||
| `terms_versions` | Versión activa, URL del documento, mensajes |
|
||||
| `terms_acceptance` | Historial de aceptaciones |
|
||||
| `system_config.terms_message` | Texto de bienvenida, **repite la URL dentro** |
|
||||
|
||||
Se vuelve a pedir la aceptación si nunca aceptó, si pasaron más de 6 meses, o si hay versión nueva con `forzar_reenvio`.
|
||||
|
||||
> La URL del documento está en **dos** lugares. Cambiar solo uno deja al otro sirviendo un enlace viejo.
|
||||
|
||||
## Configuración
|
||||
|
||||
Todo en `system_config`: `whatsapp_token`, `whatsapp_api_url`, `whatsapp_business_account_id`, `whatsapp_phone_number_id`, `whatsapp_phone_number_id_turnero`, `webhook_verify_token`.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
| Síntoma | Dónde mirar |
|
||||
|---|---|
|
||||
| Sale por el número equivocado | Falta `'turnero'` en el constructor |
|
||||
| Enlace roto en un botón | La plantilla en Meta |
|
||||
| No llega ninguna plantilla | Estado de aprobación en WhatsApp Manager |
|
||||
| Llega texto plano en vez de plantilla | El respaldo actuó: la plantilla falló |
|
||||
| El bot no responde | `webhook_logs`, horario, estado de la conversación |
|
||||
@@ -0,0 +1,65 @@
|
||||
# Domicilios
|
||||
|
||||
Visitas domiciliarias: agendamiento, asignación de enfermeros y seguimiento.
|
||||
|
||||
## Dónde está el código
|
||||
|
||||
Es de la generación anterior. El módulo es un puente:
|
||||
|
||||
```php
|
||||
// modules/lab_domicilios/views/index.php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
| Archivo | Rol |
|
||||
|---|---|
|
||||
| `lab_domicilios.php` | Pantalla administrativa |
|
||||
| `enfermero_portal.php` | Portal del enfermero, pensado para celular |
|
||||
| `classes/lab/Domicilio.php` | Reglas de negocio |
|
||||
| `classes/lab/Asignacion.php` | Vínculo enfermero–domicilio |
|
||||
| `api/lab/*.php` | Endpoints |
|
||||
|
||||
## Datos
|
||||
|
||||
```
|
||||
lab_domicilios
|
||||
├── lab_asignaciones qué enfermero atiende
|
||||
├── lab_domicilio_notas seguimiento, admite adjuntos
|
||||
└── lab_domicilio_pagos cobros
|
||||
```
|
||||
|
||||
Estados: `programado` → `en_curso` → `completado`, o `cancelado`.
|
||||
|
||||
## Permisos
|
||||
|
||||
Se combinan dos niveles.
|
||||
|
||||
**Nivel módulo** — `hasModuleWrite('lab_domicilios')` decide si aparecen los botones de crear y editar. Un rol con `permission = 'read'` ve la pantalla sin poder modificar.
|
||||
|
||||
**Nivel registro** — un enfermero solo puede editar domicilios **que creó o que tiene asignados**. Se verifica en el servidor (`api/lab/save_domicilio.php`):
|
||||
|
||||
```sql
|
||||
SELECT d.creado_por,
|
||||
(SELECT COUNT(*) FROM lab_asignaciones a
|
||||
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
|
||||
FROM lab_domicilios d WHERE d.id = ?
|
||||
```
|
||||
|
||||
> No alcanza con ocultar el botón en la vista: el endpoint verifica por su cuenta. Cualquier endpoint que modifique datos debe hacer lo mismo.
|
||||
|
||||
## Portal del enfermero
|
||||
|
||||
`enfermero_portal.php` admite `admin`, `superadmin` y `enfermero`. Los administradores pueden ver el portal de un enfermero concreto pasando `?eid=<id>`, útil para dar soporte.
|
||||
|
||||
Incluye:
|
||||
|
||||
- Agenda propia con creación y edición
|
||||
- Notas de ficha y notas libres, con fotos y archivos
|
||||
- Envío de formularios al paciente, o copia del enlace
|
||||
- Enlace a WhatsApp con el mensaje ya redactado, presentando al profesional como parte del Laboratorio Ximena Caicedo
|
||||
|
||||
## Formularios
|
||||
|
||||
Los domicilios usan la vía `lab_form_envios` (token de 64 hex, parámetro `?t=`), a diferencia del turnero que usa UUID. Ver [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios).
|
||||
|
||||
Cuando el enfermero firma, su nombre y cédula salen de `lab_enfermeras` a través de `admin_users.enfermera_id`.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Webhook de WhatsApp
|
||||
|
||||
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
|
||||
|
||||
|
||||
## Endpoint principal
|
||||
|
||||
```
|
||||
URL: /api/webhook.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET — Verificación de webhook
|
||||
|
||||
```
|
||||
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
|
||||
```
|
||||
|
||||
### Parámetros que envía Meta
|
||||
| Parámetro | Valor esperado |
|
||||
|---|---|
|
||||
| `hub.mode` | `subscribe` |
|
||||
| `hub.verify_token` | El token configurado en `system_config.webhook_verify_token` |
|
||||
| `hub.challenge` | Número aleatorio que debe devolverse tal cual |
|
||||
|
||||
### ⚠️ Bug conocido
|
||||
El código lee `$_GET['hub_verify_token']` (con guión bajo), pero PHP convierte los puntos a guiones bajos automáticamente al parsear `$_GET`, por lo que **funciona correctamente**.
|
||||
|
||||
### Respuesta exitosa
|
||||
```
|
||||
HTTP 200
|
||||
Body: {challenge}
|
||||
```
|
||||
|
||||
### Respuesta fallida
|
||||
```
|
||||
HTTP 403
|
||||
Body: {"error":"Token de verificación inválido"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST — Recepción de eventos
|
||||
|
||||
```
|
||||
POST /api/webhook.php
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Estructura del payload esperado (Meta Cloud API)
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "WABA_ID",
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"phone_number_id": "PHONE_NUMBER_ID"
|
||||
},
|
||||
"contacts": [{
|
||||
"wa_id": "573001234567",
|
||||
"profile": { "name": "Nombre Contacto" }
|
||||
}],
|
||||
"messages": [{
|
||||
"from": "573001234567",
|
||||
"id": "wamid.XXX",
|
||||
"timestamp": "1234567890",
|
||||
"type": "text",
|
||||
"text": { "body": "Hola" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Tipos de mensaje soportados
|
||||
| `type` | Descripción |
|
||||
|---|---|
|
||||
| `text` | Texto plano |
|
||||
| `image` | Imagen (con caption opcional) |
|
||||
| `audio` | Audio / nota de voz |
|
||||
| `video` | Video |
|
||||
| `document` | Documento / PDF |
|
||||
| `sticker` | Sticker |
|
||||
| `reaction` | Reacción emoji a otro mensaje |
|
||||
| `interactive` | Respuesta de lista o botón |
|
||||
|
||||
### El campo `field` del change puede ser
|
||||
- `messages` → mensajes entrantes y estados
|
||||
- `conversations` → alias aceptado también
|
||||
|
||||
### Eventos de estado (statuses)
|
||||
```json
|
||||
"statuses": [{
|
||||
"id": "wamid.XXX",
|
||||
"status": "sent|delivered|read|failed",
|
||||
"recipient_id": "573001234567"
|
||||
}]
|
||||
```
|
||||
|
||||
### Respuesta exitosa
|
||||
```
|
||||
HTTP 200
|
||||
Body: {"status":"success"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuración necesaria en `system_config` (BD)
|
||||
|
||||
| config_key | Descripción |
|
||||
|---|---|
|
||||
| `whatsapp_token` | Access Token de Meta |
|
||||
| `whatsapp_phone_number_id` | Phone Number ID de la línea |
|
||||
| `webhook_verify_token` | Token de verificación del webhook |
|
||||
| `whatsapp_api_url` | `https://graph.facebook.com/v22.0/` |
|
||||
|
||||
---
|
||||
|
||||
## Variables de entorno equivalentes (`.env`)
|
||||
|
||||
```env
|
||||
WHATSAPP_TOKEN=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WEBHOOK_VERIFY_TOKEN=
|
||||
WHATSAPP_API_URL=https://graph.facebook.com/v22.0/
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tablas BD que usa el webhook
|
||||
|
||||
| Tabla | Uso |
|
||||
|---|---|
|
||||
| `users` | Crea o busca usuario por `phone_number` |
|
||||
| `conversations` | Guarda cada mensaje (deduplicado por `message_id`) |
|
||||
| `webhook_logs` | Registra el payload crudo de cada POST |
|
||||
| `notifications` | Crea aviso de nuevo mensaje entrante |
|
||||
| `media_queue` | Encola media que no pudo descargarse en el momento |
|
||||
| `system_config` | Lee tokens y configuración |
|
||||
|
||||
---
|
||||
|
||||
## Seguridad — pendiente de implementar
|
||||
|
||||
- No valida la firma `X-Hub-Signature-256` en el POST.
|
||||
- Se recomienda agregar antes de procesar:
|
||||
```php
|
||||
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
|
||||
$expected = 'sha256=' . hash_hmac('sha256', $input, APP_SECRET);
|
||||
if (!hash_equals($expected, $signature)) {
|
||||
http_response_code(401);
|
||||
exit;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
# Endpoints
|
||||
|
||||
Inventario de los endpoints de todos los módulos, generado del filesystem en cada carga. La descripción sale del comentario de cabecera de cada archivo.
|
||||
|
||||
## Convenciones
|
||||
|
||||
Los endpoints son archivos PHP sueltos que devuelven JSON. **No pasan por el enrutador**: se invocan por su ruta real.
|
||||
|
||||
```
|
||||
modules/turnero/api/get_historial.php
|
||||
api/lab/save_domicilio.php
|
||||
```
|
||||
|
||||
Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empiezan con guión bajo son internos y no se listan acá.
|
||||
|
||||
### Helpers típicos
|
||||
|
||||
| Función | Qué hace |
|
||||
|---|---|
|
||||
| `db()` | Conexión PDO |
|
||||
| `inputJson()` | Cuerpo de la petición como arreglo |
|
||||
| `jsonOk($datos)` | Respuesta correcta |
|
||||
| `jsonError($msg, $codigo)` | Error con código HTTP |
|
||||
| `adminId()` | Id del usuario de la sesión |
|
||||
| `requireMethod('POST')` | Corta si el método no coincide |
|
||||
| `requireTurnero()` | Corta si no tiene acceso al turnero |
|
||||
|
||||
### Reglas al agregar uno
|
||||
|
||||
1. **Verificá permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
|
||||
2. **Resolvé la identidad en el servidor.** Para saber quién hace una acción, usá `adminId()`, no un valor que mande el navegador.
|
||||
3. **Consultas preparadas siempre.**
|
||||
4. **Dejá un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
|
||||
|
||||
## Inventario
|
||||
|
||||
{{endpoints}}
|
||||
|
||||
## Endpoints fuera de módulos
|
||||
|
||||
`api/lab/` agrupa los del laboratorio de la generación anterior — domicilios, pacientes, formularios, configuración. Siguen las mismas convenciones y usan `api/lab/_helpers.php`.
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* Descriptor del módulo Soporte — documentación del proyecto.
|
||||
* El manual de usuario es visible para cualquier usuario autenticado; el resto
|
||||
* de secciones (técnica, arquitectura, operación) solo para administradores.
|
||||
*/
|
||||
|
||||
$_sopEsAdmin = in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
|
||||
|
||||
$_sopLinks = [
|
||||
['name' => '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,
|
||||
];
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/documentacion.php
|
||||
* Visor de la documentación del proyecto.
|
||||
*
|
||||
* ?s=<seccion>&d=<documento> documento puntual
|
||||
* ?s=<seccion> 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');
|
||||
?>
|
||||
<style>
|
||||
.doc-wrap { display:flex; gap:0; align-items:flex-start; min-height:calc(100vh - 60px); background:#fff; }
|
||||
|
||||
/* ── Índice lateral ── */
|
||||
.doc-nav { width:280px; flex-shrink:0; border-right:1px solid #e2e8f0; background:#f8fafc;
|
||||
align-self:stretch; padding:18px 0 60px; position:sticky; top:0; max-height:100vh; overflow-y:auto; }
|
||||
.doc-nav .buscador { padding:0 16px 14px; }
|
||||
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
|
||||
border-radius:8px; background:#fff; }
|
||||
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
|
||||
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
|
||||
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
|
||||
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
|
||||
text-decoration:none; border-left:2px solid transparent; }
|
||||
.doc-nav a:hover { background:#eef2f7; color:#1e293b; }
|
||||
.doc-nav a.activo { background:#e0edff; color:#1d4ed8; font-weight:600; border-left-color:#2563eb; }
|
||||
|
||||
/* ── Resultados de búsqueda ── */
|
||||
#resultados { padding:0 16px; }
|
||||
#resultados .r { display:block; padding:8px 10px; border-radius:8px; text-decoration:none;
|
||||
margin-bottom:4px; background:#fff; border:1px solid #e2e8f0; }
|
||||
#resultados .r:hover { border-color:#2563eb; }
|
||||
#resultados .r-t { font-size:.83rem; font-weight:600; color:#1e293b; }
|
||||
#resultados .r-c { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
|
||||
#resultados .r-x { font-size:.75rem; color:#475569; margin-top:2px; }
|
||||
#resultados .vacio { font-size:.8rem; color:#94a3b8; padding:8px 4px; }
|
||||
|
||||
/* ── Contenido ── */
|
||||
.doc-main { flex:1; min-width:0; display:flex; gap:0; }
|
||||
.doc-body { flex:1; min-width:0; padding:26px 40px 80px; max-width:900px; }
|
||||
.doc-ruta { font-size:.73rem; color:#94a3b8; margin-bottom:14px; }
|
||||
.doc-ruta a { color:#64748b; text-decoration:none; }
|
||||
.doc-ruta a:hover { text-decoration:underline; }
|
||||
|
||||
.doc-body h1 { font-size:1.65rem; font-weight:800; color:#0f172a; margin:0 0 18px;
|
||||
padding-bottom:12px; border-bottom:1px solid #e2e8f0; }
|
||||
.doc-body h2 { font-size:1.2rem; font-weight:700; color:#1e293b; margin:32px 0 12px;
|
||||
padding-top:6px; scroll-margin-top:20px; }
|
||||
.doc-body h3 { font-size:1rem; font-weight:700; color:#334155; margin:24px 0 9px; scroll-margin-top:20px; }
|
||||
.doc-body h4 { font-size:.9rem; font-weight:700; color:#475569; margin:18px 0 7px; }
|
||||
.doc-body p { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; }
|
||||
.doc-body ul, .doc-body ol { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; padding-left:24px; }
|
||||
.doc-body li { margin-bottom:4px; }
|
||||
.doc-body li > ul, .doc-body li > ol { margin:4px 0 2px; }
|
||||
.doc-body a { color:#2563eb; }
|
||||
.doc-body strong { color:#0f172a; }
|
||||
.doc-body code { background:#f1f5f9; color:#be185d; padding:1px 5px; border-radius:4px;
|
||||
font-size:.83em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.doc-body pre { background:#0f172a; color:#e2e8f0; padding:14px 16px; border-radius:10px;
|
||||
overflow-x:auto; margin:0 0 15px; }
|
||||
.doc-body pre code { background:none; color:inherit; padding:0; font-size:.8rem; line-height:1.6; }
|
||||
.doc-body blockquote { border-left:3px solid #f59e0b; background:#fffbeb; margin:0 0 15px;
|
||||
padding:10px 16px; border-radius:0 8px 8px 0; }
|
||||
.doc-body blockquote p { margin:0; color:#78350f; font-size:.86rem; }
|
||||
.doc-body hr { border:none; border-top:1px solid #e2e8f0; margin:26px 0; }
|
||||
.tabla-scroll { overflow-x:auto; margin:0 0 16px; }
|
||||
.doc-body table { width:100%; border-collapse:collapse; font-size:.82rem; }
|
||||
.doc-body thead th { background:#f8fafc; text-align:left; font-weight:700; color:#475569;
|
||||
padding:8px 11px; border-bottom:2px solid #e2e8f0; white-space:nowrap; }
|
||||
.doc-body tbody td { padding:7px 11px; border-bottom:1px solid #f1f5f9; color:#334155; vertical-align:top; }
|
||||
.doc-body tbody tr:hover { background:#f8fafc; }
|
||||
|
||||
/* ── Tabla de contenidos ── */
|
||||
.doc-toc { width:210px; flex-shrink:0; padding:32px 20px 60px; position:sticky; top:0;
|
||||
max-height:100vh; overflow-y:auto; }
|
||||
.doc-toc-t { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
|
||||
color:#94a3b8; margin-bottom:9px; }
|
||||
.doc-toc a { display:block; font-size:.76rem; color:#64748b; text-decoration:none;
|
||||
padding:3px 0 3px 9px; border-left:2px solid #e2e8f0; line-height:1.4; }
|
||||
.doc-toc a:hover { color:#2563eb; border-left-color:#93c5fd; }
|
||||
.doc-toc a.n3 { padding-left:20px; font-size:.73rem; }
|
||||
|
||||
/* ── Portadas ── */
|
||||
.doc-hero { margin-bottom:26px; }
|
||||
.doc-hero h1 { font-size:1.7rem; font-weight:800; color:#0f172a; margin:0 0 8px; }
|
||||
.doc-hero p { font-size:.92rem; color:#64748b; margin:0; }
|
||||
.tarjetas { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
|
||||
.tarjeta { display:block; padding:18px; border:1px solid #e2e8f0; border-radius:12px;
|
||||
text-decoration:none; background:#fff; transition:border-color .15s, transform .15s; }
|
||||
.tarjeta:hover { border-color:#2563eb; transform:translateY(-2px); }
|
||||
.tarjeta i { font-size:1.3rem; color:#2563eb; }
|
||||
.tarjeta .t { font-size:1rem; font-weight:700; color:#1e293b; margin:9px 0 5px; }
|
||||
.tarjeta .d { font-size:.82rem; color:#64748b; line-height:1.5; }
|
||||
.tarjeta .n { font-size:.72rem; color:#94a3b8; margin-top:9px; }
|
||||
|
||||
.lista-docs { list-style:none; padding:0; margin:0; }
|
||||
.lista-docs li { border-bottom:1px solid #f1f5f9; }
|
||||
.lista-docs a { display:block; padding:12px 4px; text-decoration:none; color:#1e293b; font-size:.92rem; }
|
||||
.lista-docs a:hover { color:#2563eb; }
|
||||
|
||||
@media (max-width:1100px) { .doc-toc { display:none; } }
|
||||
@media (max-width:820px) {
|
||||
.doc-nav { position:static; width:100%; max-height:none; border-right:none;
|
||||
border-bottom:1px solid #e2e8f0; }
|
||||
.doc-wrap { flex-direction:column; }
|
||||
.doc-body { padding:20px 18px 60px; }
|
||||
}
|
||||
@media print {
|
||||
.doc-nav, .doc-toc, .doc-ruta { display:none !important; }
|
||||
.doc-body { max-width:none; padding:0; }
|
||||
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="doc-wrap">
|
||||
<!-- ── Índice lateral ── -->
|
||||
<nav class="doc-nav">
|
||||
<div class="buscador">
|
||||
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
|
||||
</div>
|
||||
<div id="resultados" style="display:none"></div>
|
||||
<div id="arbol">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<div class="doc-nav-sec">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>"
|
||||
style="padding:0;border:none;color:inherit;font:inherit;letter-spacing:inherit">
|
||||
<?= htmlspecialchars($cfg['titulo']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php foreach ($cfg['docs'] as $d): ?>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>&d=<?= urlencode($d['slug']) ?>"
|
||||
class="<?= ($sec === $seccion && $d['slug'] === $docSlug) ? 'activo' : '' ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="doc-main">
|
||||
<div class="doc-body">
|
||||
<?php if ($archivo): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
›
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
|
||||
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
|
||||
</a>
|
||||
› <?= htmlspecialchars($titulo) ?>
|
||||
</div>
|
||||
<?= $cuerpo ?>
|
||||
|
||||
<?php elseif ($seccion && isset($arbol[$seccion])): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
› <?= htmlspecialchars($arbol[$seccion]['titulo']) ?>
|
||||
</div>
|
||||
<div class="doc-hero">
|
||||
<h1><?= htmlspecialchars($arbol[$seccion]['titulo']) ?></h1>
|
||||
<p><?= htmlspecialchars($arbol[$seccion]['resumen']) ?></p>
|
||||
</div>
|
||||
<?php if ($arbol[$seccion]['docs']): ?>
|
||||
<ul class="lista-docs">
|
||||
<?php foreach ($arbol[$seccion]['docs'] as $d): ?>
|
||||
<li><a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>&d=<?= urlencode($d['slug']) ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Esta sección aún no tiene documentos.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="doc-hero">
|
||||
<h1>Documentación del sistema</h1>
|
||||
<p>Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.</p>
|
||||
</div>
|
||||
<div class="tarjetas">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<a class="tarjeta" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<div class="t"><?= htmlspecialchars($cfg['titulo']) ?></div>
|
||||
<div class="d"><?= htmlspecialchars($cfg['resumen']) ?></div>
|
||||
<div class="n"><?= count($cfg['docs']) ?> documento<?= count($cfg['docs']) === 1 ? '' : 's' ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($toc): ?>
|
||||
<aside class="doc-toc">
|
||||
<div class="doc-toc-t">En esta página</div>
|
||||
<?php foreach ($toc as $t): ?>
|
||||
<a href="#<?= htmlspecialchars($t['slug']) ?>" class="<?= $t['nivel'] === 3 ? 'n3' : '' ?>">
|
||||
<?= htmlspecialchars($t['texto']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── Buscador: índice completo servido con la página ── */
|
||||
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
const BASEQ = '<?= BASE_URL ?>erp.php?m=soporte&v=documentacion';
|
||||
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
function buscar(termino) {
|
||||
const q = norm(termino).trim();
|
||||
const cajaR = document.getElementById('resultados');
|
||||
const cajaA = document.getElementById('arbol');
|
||||
if (q.length < 2) { cajaR.style.display = 'none'; cajaA.style.display = ''; return; }
|
||||
|
||||
const palabras = q.split(/\s+/);
|
||||
const hits = [];
|
||||
DOCS.forEach(d => {
|
||||
const heno = norm(d.t + ' ' + d.c + ' ' + d.x);
|
||||
if (!palabras.every(p => heno.includes(p))) return;
|
||||
// El título pesa más que el cuerpo
|
||||
const enTitulo = palabras.every(p => norm(d.t).includes(p));
|
||||
const pos = heno.indexOf(palabras[0]);
|
||||
hits.push({ d, score: (enTitulo ? 0 : 1000) + pos, pos });
|
||||
});
|
||||
hits.sort((a, b) => a.score - b.score);
|
||||
|
||||
cajaA.style.display = 'none';
|
||||
cajaR.style.display = '';
|
||||
if (!hits.length) {
|
||||
cajaR.innerHTML = '<div class="vacio">Sin resultados para «' + esc(termino) + '»</div>';
|
||||
return;
|
||||
}
|
||||
cajaR.innerHTML = hits.slice(0, 20).map(h => {
|
||||
const ini = Math.max(0, h.pos - 40);
|
||||
const frag = h.d.x.substr(ini, 120).trim();
|
||||
return `<a class="r" href="${BASEQ}&s=${encodeURIComponent(h.d.s)}&d=${encodeURIComponent(h.d.u)}">
|
||||
<div class="r-c">${esc(h.d.c)}</div>
|
||||
<div class="r-t">${esc(h.d.t)}</div>
|
||||
<div class="r-x">${ini > 0 ? '…' : ''}${esc(frag)}…</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const inputQ = document.getElementById('q');
|
||||
let _t = null;
|
||||
inputQ.addEventListener('input', () => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(() => buscar(inputQ.value), 120);
|
||||
});
|
||||
// Atajo: "/" enfoca el buscador
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && document.activeElement !== inputQ) { e.preventDefault(); inputQ.focus(); }
|
||||
if (e.key === 'Escape' && document.activeElement === inputQ) { inputQ.value = ''; buscar(''); inputQ.blur(); }
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php Layout::close(); ?>
|
||||
Reference in New Issue
Block a user