# 02 — Arquitectura

> Este documento describe la arquitectura **real** verificada en el código, no una arquitectura ideal.
> ⚠️ El fichero preexistente `docs/02_ARCHITECTURE.md` (fuera de `knowledge/`) describe una arquitectura que **no existe en este repositorio**. Ver `18_KNOWN_ISSUES.md` § Documentación contradictoria.

---

## 1. Patrón arquitectónico

**MVC clásico de dos capas, sin capa de servicio generalizada.**

| Capa | Ubicación | Responsabilidad real |
|---|---|---|
| Entrada / Front controller | `public/index.php` + `core/AutoLoad.php` | Bootstrap, sesión, configuración, autoload, resolución y despacho del controlador, cabeceras de seguridad |
| Controlador | `app/controllers/*Controller.php` | Autorización por acción, lectura de `$_GET`/`$_POST`, orquestación, elección de vista |
| Modelo | `app/models/*Model.php` | Definición de columnas (`getOptionsAttributes()`), consultas, reglas de negocio estáticas |
| Servicio (parcial) | `app/models/*Service.php` y `services/` | Lógica que cruza varios modelos. **No es un patrón sistemático**: solo 7 clases lo siguen |
| Acceso a datos | `core/Db.php` + `core/db/*PDO.php` | Construcción de SQL por concatenación y ejecución vía PDO |
| Vista | `app/views/**/*.php` | PHP plano; recibe variables por `extract()` |
| Layout | `app/layouts/*.php` | Envoltorio HTML (Metronic), incluye menú, atajos, flashes y el contenido |

**Lo que NO existe** (importante para no inventar arquitectura):
- ❌ Middleware / pipeline de middleware
- ❌ Contenedor de inyección de dependencias
- ❌ Router declarativo o tabla de rutas
- ❌ Repositorios
- ❌ ORM, Active Record completo o QueryBuilder
- ❌ Traits compartidos (`grep "trait Has"` → 0 resultados)
- ❌ Multi-tenancy activo (los ganchos `hasTenantColumn()`/`appendTenantCriteria()` existen en `core/Model.php` pero **siempre retornan `false` / el criterio sin cambios**; ningún modelo los sobreescribe)
- ❌ Clases `Request`, `Response`, `App`, `EventDispatcher`, `MailService`, `TenantContext`, `MiddlewarePipeline`
- ❌ `app/config/Modules.php`
- ❌ `bin/make` (existen `bin/make-migration` y `bin/make-seeder`, no un generador de módulos)

---

## 2. Flujo de una petición HTTP

```mermaid
flowchart TD
    A["Navegador\nGET/POST /?c=Metas&a=list"] --> B["public/index.php\ndefine BASE_PATH, BASE_FILES, DIR_INDEX"]
    B --> C["core/AutoLoad.php"]
    C --> C1["session_start con HttpOnly,\nSecure, SameSite=Strict"]
    C1 --> C2["Env::load(.env)"]
    C2 --> C3["Config.php → ConfigEnv.php\n→ Config::syncConfigFromEnv()"]
    C3 --> C4["require core/*: Router, Html, Db,\nCache, Model, View, Lista, Logger…"]
    C4 --> C5["Composer autoload + classmap propio\n(storage/cache/classmap.php)"]
    C5 --> D{"¿existe\napp/controllers/{C}Controller.php?"}
    D -- no --> Z["UserFlash Error + redirect a home"]
    D -- sí --> E["new {C}Controller()"]
    E --> E1["__construct:\nemail_send, application, Module,\nisDemoMode, loadPermission(),\nloadSystemUser(), loadAccessControl(),\ngetTabs(), Parameters=$_GET"]
    E1 --> F["Controller::process()"]
    F --> F1{"¿Modo demo bloquea\nesta acción?"}
    F1 -- sí --> Z2["Flash + redirect a Demo/index"]
    F1 -- no --> G["validateAccess($action)"]
    G --> G1{"resultado"}
    G1 -- "NO_LOG_IN / NO_PERMISSIONS\nNO_ACTION / ERROR_ACCESS" --> Z3["redirect_to_action(DIR_INDEX)"]
    G1 -- ACCESS --> H{"¿action == 'remove'?"}
    H -- sí --> H1{"POST + CSRF válido?"}
    H1 -- no --> Z4["Flash + redirect a list"]
    H1 -- sí --> I
    H -- no --> I["$this->{action}Action()"]
    I --> J["Model::getAll / save / …\n→ DB::getConnection('klee')\n→ MysqlPDO → SQL string → PDO"]
    J --> K["View::render_view('carpeta/vista', $parameters)"]
    K --> L["include app/layouts/{layout}.php\n→ menú + shortcuts + flashes + include $content"]
    L --> M["Cabeceras de seguridad\nX-Frame-Options, CSP, HSTS…"]
    M --> N["ob_flush → HTML al navegador"]
```

### Traza textual equivalente

```
Navegador
   ↓  ?c=Metas&a=list
public/index.php            (define BASE_PATH / BASE_FILES / DIR_INDEX='public')
   ↓
core/AutoLoad.php           (bootstrap completo + despacho)
   ↓
MetasController::__construct()
   ↓  loadPermission()  → PermisosModel::getPermissions('Metas')  → $_SESSION[APP_ID]['Permissions']
   ↓  loadAccessControl() → mapa acción ⇒ '*' | '@'
MetasController::process()
   ↓  validateAccess('list')  ⇒ 'ACCESS'
MetasController::listAction()
   ↓
MetasModel::getAll(...)  /  ListaAjax
   ↓
DB::getConnection('klee') → MysqlPDO::queryAll() → "SELECT … FROM vista_metas WHERE …"
   ↓
View::render_view('metas/list', $parameters)
   ↓
app/layouts/metronic.php  → include app/views/metas/list.php
   ↓
HTML
```

---

## 3. Bootstrap detallado (`core/AutoLoad.php`)

Orden exacto de operaciones. **Alterar este orden rompe la aplicación.**

| # | Operación | Por qué importa |
|---|---|---|
| 1 | `ini_set` de `session.use_strict_mode`, `cookie_httponly`, `cookie_secure` | Endurecimiento antes de abrir la sesión |
| 2 | `session_set_cookie_params(['samesite' => 'Strict'])` | Defensa CSRF a nivel de cookie |
| 3 | `session_start()` + `ob_start()` | La salida se almacena en búfer hasta `ob_flush()` |
| 4 | `require core/Url.php` | `URL::base_url()` la usan layouts y `ROUTER` |
| 5 | `require core/Env.php` + `Env::load(BASE_PATH . ".env")` | Debe ir **antes** de `Config` |
| 6 | `require app/config/Config.php` | Configuración estructural |
| 7 | `require app/config/ConfigEnv.local.php` **si** el host es `localhost`/`127.0.0.1`, si no `ConfigEnv.php` | Sobrescrituras por instalación |
| 8 | `Config::syncConfigFromEnv()` | Inyecta el `.env` en las propiedades estáticas de `Config`. **Debe ir después de `ConfigEnv` y antes de `Controller.php`** |
| 9 | `require core/Controller.php` | Lee `Controller::$debug` inmediatamente después |
| 10 | `require core/ErrorHandler.php` si `$debug`, si no `error_reporting(E_ALL & ~E_NOTICE)` | |
| 11 | `define('VERSION')`, `setlocale`, `date_default_timezone_set('America/Bogota')` | Todo el sistema asume zona horaria de Bogotá |
| 12 | `require` de 20+ clases del core en orden fijo | Sin autoload para el core |
| 13 | `require vendor/autoload.php` si existe | Composer |
| 14 | Comprobación de extensiones `gd, mbstring, pdo, pdo_mysql, openssl` (solo `error_log` si faltan) | No aborta |
| 15 | Construcción/carga del **classmap** en `storage/cache/classmap.php` | Autoload propio |
| 16 | `spl_autoload_register` con rutas de búsqueda | Ver §4 |
| 17 | Resolución de `$_GET['c']` (por defecto `Home`) → `ucfirst($c) . 'Controller'` | El despacho real |
| 18 | `new $class_controller; call_user_func([$app,'process'])` dentro de `try/catch(Error|Exception)` | **El catch hace `echo " {$exception}"`** — expone la traza |
| 19 | Cabeceras de seguridad si `!headers_sent()` | X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP, X-XSS-Protection, HSTS si HTTPS |
| 20 | `ob_flush()` | |

### Rutas de autoload propio

```php
core/attributes/  app/models/  app/controllers/  core/helpers/
app/config/  services/  services/llm/  services/prompt/  services/asistente_ia/
```

Una clase se resuelve por **coincidencia exacta de nombre de fichero**: `MetasModel` → `app/models/MetasModel.php`. No hay namespaces en ninguna parte del código propio.

El classmap se persiste en `storage/cache/classmap.php` y se refresca solo si está vacío o si una clase no está en él (marcando `$autoloadClassMapDirty` y reescribiendo en `register_shutdown_function`). **Al añadir una clase nueva puede ser necesario borrar `storage/cache/classmap.php`** si el fichero quedó obsoleto.

---

## 4. El contrato de URL

**Formato:** `{base_url}/{DIR_LLAMADO}/?c={controlador}&a={accion}&param=valor#ancla`

- `c` — nombre del controlador **sin** el sufijo `Controller`. Se le aplica `ucfirst()`.
- `a` — nombre de la acción **sin** el sufijo `Action`. Si falta, se usa `$ActionDefault` (por defecto `'list'`).
- El resto de parámetros van como query string y quedan en `$this->Parameters` (todo `$_GET` menos `c` y `a`).

**`ROUTER::create_action_url()` no es solo un constructor de URL: también verifica permisos.** Si `PermisosModel::hasAccess($controller, $action)` es falso, devuelve `'#'`. Esto es lo que hace que los enlaces del menú desaparezcan (`UsuariosModel::validarMenuItems()` descarta los ítems cuya URL sea `'#'`).

```php
ROUTER::create_action_url('Metas', 'view', array('Id' => 5), 'tab_general');
// → https://host/?c=Metas&a=view&Id=5#tab_general   (o '#' si no hay permiso)

ROUTER::redirect_to_action('Metas', 'list');   // header Location + exit()
```

> **Regla dura:** el contrato `?c=&a=` es la base de todo el sistema (menú, permisos, quick-search, DataTables, formularios). No se debe sustituir por rutas limpias sin reescribir `Menu`, `PermisosModel`, `ListaAjax` y las 291 vistas.

---

## 5. El `Controller` base (`core/Controller.php`, 642 líneas)

`abstract class Controller extends ConfigEnv` — **el controlador hereda de la configuración**. Por eso `$this->application`, `$this->layout`, `static::$appColor`, `static::$FILTERS_SESION` están disponibles sin inyección.

```
Config  ←  ConfigEnv  ←  Controller  ←  XxxController
```

### Propiedades que define cada controlador concreto

| Propiedad | Tipo | Efecto |
|---|---|---|
| `public static $MODULE_NAME` | string | Clave de permisos (`permisos.ModuleName`) y valor de `$this->Module` usado en redirecciones |
| `public static $TITLE_NAME` | string | Título visible (`loadMetadata()`) |
| `protected $ViewFolder` | string | Carpeta bajo `app/views/` |
| `protected $MyLayout` | string | Clave de `$this->layout` (`admin`, `metronic`, `metronicPublic`, `metronicEmpty`, `impresion`, `empty`, `demo`…). Si no se declara, hereda `Config::$MyLayout = 'metronic'` |
| `protected $ActionDefault` | string | Acción cuando falta `?a=` (por defecto `'list'`) |
| `protected function loadAccessControl()` | método | Mapa `accion => '*' | '@'` |
| `protected function getListAjaxObject()` | método | Devuelve el `ListaAjax` para `listAction()`/`dataListAjaxAction()` |
| `public static function getTabs()` | método | Pestañas sujetas a `permisos.Tabs` |

### Métodos heredados relevantes

| Método | Qué hace |
|---|---|
| `process()` | `final`. Punto de entrada. Bloqueo demo → `validateAccess` → guardia CSRF para `remove` → despacho |
| `validateAccess($action)` | `final`. Devuelve `ACCESS` \| `NO_ACTION` \| `NO_PERMISSIONS` \| `NO_LOG_IN` \| `ERROR_ACCESS` |
| `validateSession()` | `final`. `isset($_SESSION[APP_ID]['User'])` |
| `hasPublicCollaboratorSession()` | `final`. Detecta sesión de portal |
| `isPublicCollaboratorRouteAllowed($clase,$accion)` | `final static`. Lista blanca `$PUBLIC_COLLABORATOR_ROUTES` |
| `regenerateSessionOnAuthentication()` | `final static`. `session_regenerate_id(true)` + rotación de token CSRF |
| `loadMetadata()` | Construye el array `$parameters` común a todas las vistas |
| `generateCsrfToken()` / `validateCsrfToken()` / `validateCsrfTokenNoRotate()` | Gestión del token CSRF por sesión |
| `listAction()` / `dataListAjaxAction()` | CRUD genérico basado en `ListaAjax` |
| `loadFiltersSesion()` / `changeFiltersSesionAction()` / `filtersSesionValuesDefault()` | Filtros globales de sesión (Estado, Periodo) |

### El array `$parameters` que reciben todas las vistas

`loadMetadata()` devuelve:

```php
[
  'meta'             => ['title','description','keywords','robots'],
  'application'      => ['name','tooltip','starturl'],
  'Menu'             => $_SESSION[APP_ID]['Menu'],        // si existe
  'Shortcuts'        => $_SESSION[APP_ID]['Shortcuts'],   // si existe
  'appColor'         => Config::$appColor,
  'controllerName'   => $this->Module,      // ← lo usa View para resolver el layout
  'currentAction'    => $this->CurrentAction,
  'parameters'       => $this->Parameters,  // $_GET sin c ni a
  'permission'       => $this->Permission,  // ['create'=>0|1,'view'=>…,'edit'…,'remove'…,'list'…,'export'…]
  'branding'         => ['nombre_institucion','logo_login','site_name','favicon'],
  'subtituloSection' => '<html del selector de periodo>',
]
```

En la vista, `View::render_view()` hace `extract($parameters)`, de modo que `$permission`, `$controllerName`, `$currentAction`, `$model`… son variables locales.

---

## 6. El `Model` base (`core/Model.php`, 994 líneas)

`abstract class Model`. Un modelo declara su tabla, su vista SQL de lectura y sus columnas.

```php
class CompetenciasModel extends Model
{
    protected static $TABLE_NAME = 'competencias';
    protected static $VIEW_NAME  = 'competencias';
    protected static $CONNECTION_NAME = 'klee';   // heredado
    protected static $LOG   = false;              // auditoría en log_modules
    protected static $CACHE = false;              // caché de lectura en ficheros

    public static function getOptionsAttributes()
    {
        return array(
            array('Type' => 'AutoincrementId', 'Name' => 'Id'),
            array('Type' => 'text', 'Name' => 'Nombre', 'MaxLength' => 250),
            // …
        );
    }
}
```

### El sistema de atributos

`getAttributes()` convierte cada entrada de `getOptionsAttributes()` en una instancia de la clase indicada por `Type` (`ucfirst($type)`), buscada en `core/attributes/`. Hay **26 tipos**:

`AutoincrementId · Checkbox · Consecutive · Date · Decimal · Document · Email · Encrypted · FechaHora · Hidden · HtmlEditor · Image · Integer · ModificationDate · ModificationUser · Money · Password · Radio · RegistrationDate · RegistrationUser · Select · Slider · Text · Textarea · Time · UniqueId`

Todos extienden `Atributo` (`core/Atributo.php`), que aporta:
- `receiveData()` — lee `$_POST[$modelName][$attrName]`, normaliza (trim, quita `\r\n\t`, opcionalmente `strtoupper` si `Config::$valuesToUpper`), valida requerido y valida el tipo.
- `validateRequired()`, `validateLength()`, y `validateData()` abstracto por tipo.
- `CanEditOnCreate()` / `CanEditOnUpdate()` — controlan si la columna entra en el INSERT/UPDATE.
- Magia `__get`/`__set`/`__call` para `getX()`/`setX()`.

> **De aquí sale la convención de formularios:** el `name` del input debe ser `NombreDelModelo[NombreDelAtributo]`.

### Ciclo de persistencia

```mermaid
flowchart LR
    A["save(attrs)"] --> B{"¿POST HTTP?"}
    B -- sí --> C{"validateCsrfToken(_csrf_token)"}
    C -- no --> X["Flash 'Token de seguridad inválido'\nreturn false"]
    C -- sí --> D
    B -- no (CLI) --> D{"Id == null ?"}
    D -- sí --> E["saveOnCreate()"]
    D -- no --> F["saveOnUpdate()"]
    E --> E1["beforeCreate() → receiveData('Create')"]
    E1 --> E2["reúne Fields/Values de\natributos con CanEditOnCreate"]
    E2 --> E3["MysqlPDO::insert() → INSERT"]
    E3 --> E4["Id = lastInsertId(); log('Creación') si LOG"]
    F --> F1["beforeUpdate() → receiveData('Update')"]
    F1 --> F2["MysqlPDO::updateId() → UPDATE … WHERE Id"]
    F2 --> F3["log('Edición') con diff old/new si LOG"]
```

Dos familias de escritura, **con semántica distinta**:

| Método | Lee de `$_POST` | Valida CSRF | Uso típico |
|---|---|---|---|
| `save($attrs)` / `saveOnCreate` / `saveOnUpdate` | **Sí**, vía `receiveData()` | Sí, en `save()` | Formularios POST clásicos |
| `saveOnly($action)` / `saveOnlyOnCreate` / `saveOnlyOnUpdate` | **No** — usa los valores ya asignados | No | Escrituras programáticas |
| `createFromParameters(array)` | No | No | Inserciones desde código (la más usada en los módulos nuevos) |
| `editFromParameters(array, $criteria)` | No | No | Actualizaciones parciales por criterio (**estática**) |
| `saveAjax()` | Sí (`$_POST[Clase]['attribute']`) | No | Edición inline de un solo campo |

### Lectura

| Método | Fuente | Devuelve |
|---|---|---|
| `getById($id, $fields)` | `$TABLE_NAME` | fila (array mixto) o `false` |
| `getByIdView($id, $fields)` | `$VIEW_NAME` | fila |
| `getByCriteria($fields, $criteria, $filtersGeneral)` | `$TABLE_NAME`, `LIMIT 1` | fila |
| `getByCriteriaView(...)` | `$VIEW_NAME`, `LIMIT 1` | fila |
| `getAll($fields, $criteria, $filtersGeneral, $fromView=false)` | tabla o vista | array de filas (`FETCH_BOTH`) |
| `getAllByNames(...)` / `getAllViewByNames(...)` | | array de filas (`FETCH_ASSOC`) |
| `getQuantity($criteria)` / `getQuantityView(...)` | | `['Numero' => n]` |
| `queryAllSql($sql)` | SQL literal | array de filas |
| `loadById($id)` | tabla | hidrata `$this->Attributes` y llama `saveOld()` |
| `findByAttribute` / `findByAttributes` / `findByCriteria` / `findById` | | `$this` hidratado o `null` |

Todos los métodos de lectura pasan por `addFiltersSesion()` y `appendTenantCriteria()` antes de construir el SQL.

### `$CACHE` y `$LOG`

- **`$CACHE = true`** activa caché en fichero (`core/Cache.php`, TTL 300 s) para `getById`, `getByCriteria` y `getAll`. Se invalida por *namespace* = nombre de la clase, en `createFromParameters()` y `editFromParameters()`. **Ningún modelo lo tiene activado actualmente.**
- **`$LOG = true`** registra cada Creación/Edición/Eliminación en `log_modules` con `OldData`/`NewData` en JSON, y replica en fichero vía `LoggerManager::logAction()`. Solo lo activan **`ColaboradoresModel`** y **`UsuariosModel`**. Requiere además `Config::$LOG_MODULES = true`.

---

## 7. Filtros globales de sesión

`Config::$FILTERS_SESION` define filtros que se inyectan automáticamente en **toda** consulta de modelo:

```php
'Estado'  => ['name'=>'Estado',  'tags'=>['Estado','IdEstado'],   'form'=>false, 'valueDefault'=>1]
'Periodo' => ['name'=>'Periodo', 'model'=>'PeriodosModel',
              'tags'=>['Periodo','IdPeriodo'], 'form'=>true, 'valueDefault'=>29]
```

`Model::addFiltersSesion()` comprueba si el modelo tiene alguna columna cuyo nombre esté en `tags`; si la tiene y hay valor en `$_SESSION[APP_ID]['filtersSesion'][$name]`, añade la condición:

```sql
FIND_IN_SET('{$valor}', {$nombreColumna})
```

El filtro `Periodo` se muestra como un `<select>` en la barra superior (`Controller::loadFiltersSesion()`) y se cambia con `POST ?c=home&a=changeFiltersSesion`.

**Para desactivarlo en una consulta concreta se pasa `array()` como tercer argumento:**

```php
MetasModel::getAll(array('*'), $criteria, array());        // sin filtros de sesión
MetasModel::getAll(array('*'), $criteria, array('Estado')); // solo el filtro Estado
MetasModel::getAll(array('*'), $criteria);                 // todos (por defecto array('*'))
```

> ⚠️ Este es un comportamiento **muy fácil de pasar por alto** y causa resultados vacíos inesperados. El uso mayoritario en el código nuevo es pasar `array()`.

---

## 8. Capa de datos

`DB::getConnection('klee')` devuelve la instancia PDO de la conexión nombrada, creándola en la primera llamada y guardándola en `Controller::$DB_CONNECTIONS['klee']['instance']`.

Drivers soportados en `core/Db.php`: `mysql`, `sqlite`, `pgsql`, `sqlsrv`, `oracle`, `ldap`. **Solo `mysql` está en uso.**

`MysqlPDO extends PDO implements KleePDO`. Métodos principales: `sql`, `querySql`, `queryAllSql`, `queryInt`, `queryId`, `queryAll`, `insert`, `update`, `updateId`, `updateCriteria`, `updateUniqueField`, `delete`, `deleteId`, `deleteByCriteria`, `validateUser`, `lastId`, `duplicateCheck`, `quantity`.

### El lenguaje de criterios

```php
$criteria = array(
    'WHERE' => array(
        array('name' => 'Estado', 'value' => 1),                                 // Estado ='1'
        array('name' => 'Peso', 'operator' => '>=', 'value' => 10),              // Peso >='10'
        array('name' => 'Nombre', 'operator' => 'LIKE', 'value' => '%ana%'),
        array('name' => 'Id', 'operator' => 'IN', 'separatorValues' => '',
              'value' => "(1,2,3)"),                                             // sin comillas
        array('name' => 'X', 'value' => 1, 'operator_logic' => 'OR'),            // conector con el anterior
        array('type' => 'internal', 'conditions' => array( /* … */ )),           // grupo entre paréntesis
    ),
    'GROUP_BY' => array('COLUMN' => array('IdColaborador')),
    'ORDER_BY' => array('COLUMN' => array('Id'), 'ORDEN' => array('DESC')),
    'LIMIT'    => array('START' => 0, 'END' => 20),
);
```

`MysqlPDO::criteriaToSql()` traduce esto a SQL. Reglas de la traducción:
- `operator` por defecto `=`.
- `separatorValues` por defecto `'` (comilla simple). Ponerlo a `''` inserta el valor **sin comillas** — es como se hacen los `IN (...)` y las subconsultas.
- Los elementos se unen con `AND` salvo que traigan `operator_logic`.
- Un elemento con `type => 'internal'` genera un grupo `( … )`.

> 🔴 **`criteriaToSql()` no escapa los valores.** Concatena directamente. La seguridad depende por completo de que quien construye el criterio haya saneado la entrada (típicamente con `(int)` o `trim()`). Ver `18_KNOWN_ISSUES.md` § HR-SQL-1.

### Escapado en escrituras

`insert()`, `update()`, `updateCriteria()` y `updateUniqueField()` aplican `addslashes()` a los **valores** y los envuelven en comillas simples (salvo la cadena literal `'null'`). Los **nombres de columna y tabla nunca se escapan ni se validan.**

---

## 9. Vistas y layouts

```mermaid
flowchart TD
    A["Controller\nView::render_view('metas/list', \$parameters)"] --> B["extract(\$parameters)"]
    B --> C{"¿\$layoutName es null?"}
    C -- sí --> D["new {controllerName}Controller\n→ getLayout()"]
    C -- no --> E
    D --> E["\$content = app/views/metas/list.php"]
    E --> F["include app/layouts/{layout}.php"]
    F --> G1["load_view menus/{layout}.php"]
    F --> G2["load_view shortcuts/{layout}.php"]
    F --> G3["load_view flashes/{layout}.php"]
    F --> G4["include \$content   ← la vista"]
    F --> G5["load_view logs/{layout}.php"]
    F --> G6["include alerts/metronic.php"]
    F --> G7["include _asistente_ia_widget.php"]
```

> ⚠️ `View::render_view()` **instancia un segundo controlador** solo para preguntarle su layout. Eso vuelve a ejecutar `loadPermission()`, `loadAccessControl()` y `getTabs()`. Es un coste real por petición y un efecto colateral no evidente.

### Mapa de layouts (`Config::$layout`)

| Clave `$MyLayout` | Fichero | Uso |
|---|---|---|
| `admin`, `metronic` | `app/layouts/metronic.php` | Backoffice (por defecto) |
| `metronicPublic` | `app/layouts/metronic_public.php` | Portal del colaborador |
| `adminLogin`, `metronicEmpty`, `externo` | `app/layouts/metronic_empty.php` | Login, sincronización |
| `impresion` | `app/layouts/impresiones.php` | Comprobantes de nómina |
| `impresionPOS` | `app/layouts/impresionesPOS.php` | **El fichero no existe** → ver `18_KNOWN_ISSUES.md` |
| `empty` | `app/layouts/empty.php` | Respuestas AJAX/JSON |
| `demo` | `app/layouts/demo.php` | Modo demo |
| *(sin clave)* | `app/layouts/clear.php` | Usado explícitamente por `View::stream_view(..., 'clear', ...)` |

Cada layout tiene parciales hermanos en `app/layouts/menus/`, `shortcuts/`, `flashes/`, `logs/` con **el mismo nombre de fichero**. Si añades un layout nuevo, debes crear los cuatro parciales o `View::load_view()` registrará "Vista no encontrada" en `LogsConsole`.

---

## 10. Diagrama de componentes

```mermaid
graph TB
    subgraph Cliente
      BR["Navegador\nMetronic 8 + jQuery + DataTables"]
    end

    subgraph "Entrada"
      IDX["public/index.php"]
      AL["core/AutoLoad.php\nbootstrap + despacho"]
    end

    subgraph "Configuración"
      ENV[".env"]
      CFG["Config → ConfigEnv"]
      MENU["Menu.php\nQuickActionsConfig.php"]
    end

    subgraph "Capa de aplicación"
      CTRL["75 controladores\napp/controllers/"]
      MOD["127 modelos + 12 servicios\napp/models/"]
      SRV["services/\nasistente_ia · llm · prompt"]
      VIEW["291 vistas\napp/views/"]
      LAY["8 layouts\napp/layouts/"]
    end

    subgraph "Core del framework"
      CB["Controller"]
      MB["Model + 26 Atributos"]
      LST["Lista / ListaAjax"]
      SEC["RateLimiter · PasswordPolicy\nUserFlash · Cache"]
      LOG["Logger · LoggerManager\nLoggerConfig · LogsConsole"]
      DBL["Db · KleePDO · MysqlPDO"]
    end

    subgraph "Persistencia y ficheros"
      MY[("MySQL 'kuorum'\n124 tablas · 62 vistas")]
      FS["files/\nadjuntos y logos"]
      ST["storage/cache/\nclassmap + caché"]
      LGS["logs/"]
    end

    subgraph "Externos"
      SMTP["SMTP\nPHPMailer"]
      OLL["Ollama\nLLM local"]
      MSFT["Microsoft Entra ID\nOAuth 2.0"]
      OAI["OpenAI"]
    end

    BR --> IDX --> AL
    ENV --> CFG --> AL
    MENU --> CTRL
    AL --> CTRL
    CTRL --> CB
    CTRL --> MOD
    CTRL --> SRV
    CTRL --> VIEW --> LAY --> BR
    MOD --> MB --> DBL --> MY
    CTRL --> LST --> MOD
    CB --> SEC
    MOD --> LOG --> LGS
    MOD --> ST
    CTRL --> FS
    CTRL --> SMTP
    SRV --> OLL
    SRV --> MY
    CTRL --> MSFT
    CTRL --> OAI
```

---

## 11. Decisiones arquitectónicas implícitas que hay que respetar

| Decisión | Consecuencia práctica |
|---|---|
| Enrutamiento por query string | No introducir rutas limpias sin reescribir `Menu`, `PermisosModel::hasAccess`, `ListaAjax` y todas las vistas |
| Autorización en el constructor del controlador | Toda acción nueva **debe** declararse en `loadAccessControl()`, o queda denegada para todos |
| Permisos persistidos en `permisos` por `ModuleName` | Un módulo nuevo necesita fila en `permisos` para cada rol que deba usarlo |
| Vistas SQL `vista_*` para lectura | Los modelos leen de `$VIEW_NAME` en `getAllView`/`getByIdView`. Cambiar una tabla obliga a revisar su vista |
| `$_POST['Modelo']['Campo']` | Nombres de input con espacio de nombres, siempre |
| CSRF por token de sesión rotativo | Todo formulario POST necesita `<input name="_csrf_token">` |
| Sin namespaces | Los nombres de clase son globales: no puede haber dos clases con el mismo nombre en las rutas de autoload |
| Sin `declare(strict_types)` | Comparaciones laxas por todas partes; usar casts explícitos |
| Zona horaria fija America/Bogota | Todos los `date()`/`NOW()` asumen Colombia |

---

## Documentos relacionados
- [03_PROJECT_STRUCTURE.md](03_PROJECT_STRUCTURE.md)
- [06_BACKEND.md](06_BACKEND.md)
- [09_AUTHENTICATION_AUTHORIZATION.md](09_AUTHENTICATION_AUTHORIZATION.md)
- [14_DEVELOPMENT_GUIDELINES.md](14_DEVELOPMENT_GUIDELINES.md)
