Compare commits

2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Opus 5 32c209710c Documentación completa del proyecto en el módulo Soporte
21 documentos en cuatro secciones, escritos sobre el comportamiento real del
sistema —incluidos los casos que costaron diagnosticar esta semana.

Manual de usuario (visible para todos): primeros pasos, recepción, toma de
muestras, portal del enfermero y administración. Orientado a tareas concretas,
no a describir pantallas.

Documentación técnica: índice de módulos, turnero, formularios y firma digital,
WhatsApp y bot, domicilios, webhook (migrado de WEBHOOK_ENDPOINTS.md) e
inventario de endpoints.

Arquitectura: visión general, enrutamiento y registro de módulos, roles y
permisos, modelo de datos, integración con WhatsApp, y decisiones tomadas con
su deuda técnica asociada.

Operación: runbook de incidentes ordenado por síntoma, configuraciones críticas
—incluido qué vive en Meta y no en la base— y despliegue.

Se documentan explícitamente las trampas conocidas: role/role_id que hay que
mantener sincronizados, las columnas can_* que el control de acceso no lee, las
URL de plantilla que no se cambian desde el código, y las columnas históricas
que quedaron en NULL sin forma de recuperarlas.

README_DOCS.md apunta al módulo y explica cómo agregar páginas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:14:32 -05:00
Lizandro GuarnizoandClaude Opus 5 018fb13332 Módulo Soporte: visor de documentación con renderizado Markdown y buscador
Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones:
manual de usuario (visible para todos), y documentación técnica, arquitectura
y operación (solo administradores).

- Markdown.php: renderizador propio del subconjunto que usa la documentación
  (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto
  antes de aplicar formato, así que los .md no pueden inyectar HTML. Se
  prefirió un archivo auditable a incorporar una dependencia externa.
- DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección
  y construye el índice del buscador.
- Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}},
  {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para
  que los inventarios no puedan quedar desactualizados.

Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:07:06 -05:00
28 changed files with 2841 additions and 0 deletions
+45
View File
@@ -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.
+1
View File
@@ -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 ─────────────────────────────────────────────────
+131
View File
@@ -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;
}
}
+220
View File
@@ -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;
}
}
+238
View File
@@ -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)
+104
View File
@@ -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 enfermerodomicilio |
| `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`.
+166
View File
@@ -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`.
+36
View File
@@ -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,
];
+296
View File
@@ -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>
&nbsp;&nbsp;
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
</a>
&nbsp;&nbsp;<?= 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>
&nbsp;&nbsp;<?= 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(); ?>