# 02 — Arquitectura y framework Klee

## 2.1 Stack

| Capa | Tecnología |
|---|---|
| Lenguaje | PHP (usa `readonly`-free syntax pero sí tipos de propiedad PHP 7.4+ y `?:`; probado sobre PHP 7.4/8.x) |
| Framework | **Propio, "Klee Framework"** (`core/`), sin dependencias de framework externo |
| Base de datos | MySQL 8 (`evaluacion_docente`), acceso vía PDO envuelto en `MysqlPDO` |
| Front | Plantilla **Metronic v8.0.23 (demo1)** + Bootstrap 5 + jQuery + DataTables + Highcharts |
| Dependencias Composer | `symfony/http-foundation`, `phpmailer/phpmailer`, `mpdf/mpdf`, `sgraaf/chatgpt-php` (esta última ya en desuso, sustituida por Ollama) |

## 2.2 Estructura de carpetas

```
app/
  config/       Config.php (defaults) · ConfigEnv.php (secretos, NO versionado)
                Menu.php · GeneralDataArray.php (catálogos) · Titles.php · Sedes.php ...
  controllers/  57 controladores, uno por módulo
  models/       ~70 modelos (uno por tabla + algunos "de servicio" sin tabla)
  services/     OllamaService.php
  views/        281 vistas .php, agrupadas por módulo
  layouts/      metronic.php, metronic_public.php, metronic_empty.php, empty.php,
                clear.php, impresiones.php + subcarpetas menus/ shortcuts/ flashes/ logs/
  fonts/        Fuentes para firmas manuscritas generadas por imagen
core/           El framework (ver 2.4)
public/         Document roots. index.php (portal) y admin/index.php (backoffice)
                + metronic_v8.0.23_html_demo1/ (assets de la plantilla, NO versionada)
files/          Uploads y branding (logos, firmas de usuario, adjuntos) — NO versionado
```

Archivos **ignorados por git** que hay que proveer en cada entorno (`.gitignore`):
`app/config/ConfigEnv.php`, `app/config/DataEnv.php`, `vendor/`,
`public/metronic*/`, `files/configuraciones/`, `files/usuarios/`, `files/ayuda/`.

## 2.3 Ciclo de vida de una petición

Todo entra por un único front controller. **No hay URLs amigables**: el enrutamiento es por
query string `?c=<controlador>&a=<accion>&<params>`.

```
public/index.php  (o public/admin/index.php)
  define BASE_PATH, BASE_FILES, INC_DIR, DIR_LLAMADO, DIR_INDEX
  └─> core/AutoLoad.php
        session_start(); ob_start();
        require Url.php, Config.php, ConfigEnv.php, Controller.php
        (si Controller::$debug) ErrorHandler.php
        constantes VERSION/FECHA_VERSION, locale es_CO, TZ America/Bogota
        require Router, Html, Db, KleePDO, Menu, Atributo, Model, ModelArray,
                View, LogsConsole, UserFlash, Lista, ListaAjax, QueueCss, QueueScripts,
                DebugHelper, vendor/autoload.php
        spl_autoload_register  →  core/attributes/ · app/models/ · app/controllers/
                                  core/helpers/ · app/config/ · app/services/
        $controller = $_GET['c'] ?: 'Home'
        $class = ucfirst($controller)."Controller"
        new $class  →  $app->process()
```

`Controller::process()` (final, en `core/Controller.php`):

1. Toma `$_GET['a']` o el `ActionDefault` del controlador.
2. Llama `validateAccess($action)` → devuelve `ACCESS` / `NO_LOG_IN` / `NO_PERMISSIONS` / `NO_ACTION` / `ERROR_ACCESS`.
3. Si es `ACCESS`, invoca `$this->{$action.'Action'}()`. En cualquier otro caso, redirige a `DIR_INDEX`.

> Consecuencia importante: **una acción sólo existe si está declarada en `loadAccessControl()`
> Y existe el método `<accion>Action()`**. Olvidar cualquiera de las dos cosas produce una
> redirección silenciosa al login, no un error.

El constructor del controlador ya ha hecho, antes de eso:
`loadPermission()` (permisos del rol para este módulo), `loadSystemUser()`, `getTabs()`
y ha copiado `$_GET` (sin `c` ni `a`) a `$this->Parameters`.

## 2.4 El `core/` pieza por pieza

| Archivo | Responsabilidad |
|---|---|
| `AutoLoad.php` | Bootstrap y front controller (descrito arriba) |
| `Controller.php` | Clase base abstracta. Control de acceso, metadata de la vista, filtros de sesión, CRUD por defecto (`listAction`, `dataListAjaxAction`) |
| `Model.php` | ORM ligero: atributos tipados, CRUD, `criteria`, logging de cambios |
| `Atributo.php` + `core/attributes/*` | Sistema de tipos de campo (ver 2.6) |
| `Db.php` | Fábrica de conexiones. Soporta mysql, pgsql, sqlsrv, sqlite, oracle, ldap |
| `db/KleePDO.php` | Interfaz que deben cumplir los drivers |
| `db/MysqlPDO.php` | Implementación real usada. Construye el SQL a partir del array `criteria` |
| `Router.php` | `ROUTER::create_action_url()` y `ROUTER::redirect_to_action()`. **`create_action_url` devuelve `#` si el usuario no tiene permiso** — así se ocultan enlaces y se filtra el menú |
| `Url.php` | `URL::base_url()` calculado desde `$_SERVER` + `INC_DIR` |
| `View.php` | `render_view` (vista + layout completo), `stream_view` (devuelve HTML como string), `load_view` (parcial) |
| `Lista.php` / `ListaAjax.php` | Generador de tablas DataTables server-side (ver 2.7) |
| `UserFlash.php` | Mensajes flash en sesión (`Success`, `Error`, `Warning`) |
| `LogsConsole.php` | Log de depuración en sesión, pintado por `app/layouts/logs/` |
| `QueueCss.php` / `QueueScripts.php` | Cola de assets por request: `QueueScripts::pushAfter($src)` desde el controlador, se vuelca en el `<head>`/pie del layout |
| `helpers/` | `KHtml` (constructor de HTML tipo Yii), `PassHelper` (hash), `DateHelper`, `MoneyHelper`, `NumberHelper`, `TextHelper`, `TimeHelper`, `KleePicture`, `PHPImage`, `QuitarTildesHelper`, `DebugHelper` |
| `Instalador.php`, `Migracion.php`, `Htaccess.php`, `Formvalidate.php`, `ModelArray.php` | Utilidades marginales o incompletas (`Instalador` está marcado `TODO`) |
| `CheckSession.php` / `check_sessions.js` | Ping de sesión desde el navegador (duplicado en `public/check_session.php`) |

## 2.5 El ORM: `Model` y el DSL `criteria`

Un modelo declara sus columnas en `getOptionsAttributes()` y hereda todo el CRUD:

```php
class PeriodosModel extends Model
{
    protected static $TABLE_NAME = 'periodos';   // tabla de escritura
    protected static $VIEW_NAME  = 'periodos';   // vista de lectura (a menudo vista_*)
    protected static $LOG = true;                // audita cambios en log_modules

    public static function getOptionsAttributes()
    {
        $Attributes = [];
        $Attributes[] = ['Type' => 'AutoincrementId', 'Name' => 'Id'];
        $Attributes[] = ['Type' => 'text', 'Name' => 'Nombre', 'Required' => true];
        // ...
        return $Attributes;
    }
}
```

### Métodos estáticos de consulta (leen tabla o vista)

| Método | Devuelve |
|---|---|
| `getById($id, $fields)` / `getByIdView($id, $fields)` | Una fila |
| `getByCriteria($fields, $criteria, $filtersGeneral)` / `...View` | Una fila (`LIMIT 1`) |
| `getAll($fields, $criteria, $filtersGeneral, $fromView)` / `getAllView(...)` | Array de filas |
| `getQuantity($criteria)` / `getQuantityView` | `['Numero' => n]` |
| `queryAllSql($sql)` / `sql($sql)` | Escape hatch a SQL crudo |
| `describeTable()` | `DESCRIBE <tabla>` |

### Métodos de instancia (escriben)

| Método | Uso |
|---|---|
| `loadById($id)` | Hidrata el objeto desde la BD y guarda una copia "old" para el diff de auditoría |
| `save($attributes)` | `saveOnCreate`/`saveOnUpdate` según haya `Id`. **Lee los valores de `$_POST[ClaseModelo][Campo]`** |
| `saveOnly($action)` | Igual pero **sin leer `$_POST`**: usa los valores ya seteados por `setX()`. Es el que se usa en importaciones y procesos batch |
| `createFromParameters($params)` | Setea desde array y crea |
| `editFromParameters($params, $criteria, $filtersGeneral)` | UPDATE masivo por criteria (estático) |
| `saveAjax()` | Actualiza un único campo, leyendo `$_POST[Clase]['attribute']` |
| `delete()` / `deleteById($id)` / `deleteByCriteria1($criteria, $filters, $allMatches)` | Borrado físico |

> **Distinción crítica**: `save()` repuebla el modelo desde `$_POST` (`receiveData`), mientras
> `saveOnly()` no. Usar `save()` en un proceso sin formulario borra los datos.

### El array `criteria`

Es el DSL de consultas. Lo traduce `MysqlPDO::criteriaToSql()`:

```php
$criteria = [
    'WHERE' => [
        ['name' => 'IdPeriodo', 'value' => 29],                        // AND por defecto, operador '='
        ['name' => 'Estado', 'operator' => '!=', 'value' => 0],
        ['name' => 'Tipo', 'operator' => 'IN',
         'separatorValues' => '', 'value' => '(2,3)'],                 // sin comillas
        ['name' => 'Observaciones', 'operator' => '',
         'separatorValues' => '', 'value' => 'IS NULL'],               // truco para NULL
        ['name' => 'X', 'value' => 1, 'operator_logic' => 'OR'],       // cambia el conector
        ['type' => 'internal', 'conditions' => [ /* subgrupo entre paréntesis */ ]],
    ],
    'GROUP_BY' => ['COLUMN' => ['DocenteDocumento']],
    'ORDER_BY' => ['COLUMN' => ['Nombre','Fecha'], 'ORDEN' => ['ASC','DESC']],
    'LIMIT'    => ['START' => 0, 'END' => 100],
];
```

Reglas prácticas:
- `separatorValues` es la comilla que envuelve el valor; se pone `''` para `IN (...)`, `IS NULL`, subconsultas o funciones.
- `operator_logic` en un elemento cambia el conector **que lo precede** (por defecto `AND`).
- `'type' => 'internal'` agrupa condiciones entre paréntesis.
- Los `$fields` admiten expresiones SQL: `["AVG(CASE WHEN Metodologia='P' THEN Resultado END) AS P"]`.

⚠️ **Los valores se interpolan directamente en el SQL** (no hay bind de parámetros). Ver
[06 — Seguridad](06-seguridad-permisos.md#61-inyección-sql).

### Filtros de sesión (`filtersGeneral`)

El tercer parámetro de casi todos los `get*` decide si se aplican los **filtros globales de sesión**
definidos en `Config::$FILTERS_SESION` (hoy: `Estado` y `Periodo`).

- `array('*')` (default) → aplica todos los filtros que apliquen.
- `array()` → **no aplica ninguno**: se usa cuando la consulta ya filtra el periodo a mano o cuando la tabla no tiene esas columnas.
- `array('Periodo')` → aplica solo ese.

`Model::addFiltersSesion()` inyecta el filtro como `FIND_IN_SET('<valor>', <Columna>)`, lo que permite
que un registro pertenezca a varios periodos/estados separados por coma.

## 2.6 Atributos (tipos de campo)

Cada columna es un objeto que hereda de `Atributo` (`core/attributes/`). El tipo decide validación,
comportamiento en create/update y a veces efectos automáticos:

`AutoincrementId`, `UniqueId`, `Consecutive`, `Text`, `Textarea`, `HtmlEditor`, `Integer`,
`Decimal`, `Money`, `Date`, `FechaHora`, `Time`, `Email`, `Password`, `Encrypted`, `Checkbox`,
`Radio`, `Select` (con `Multiple => true` guarda CSV), `Slider`, `Image`, `Document`, `Hidden`,
`RegistrationUser`, `RegistrationDate`, `ModificationUser`, `ModificationDate`.

Opciones habituales en la declaración: `Name`, `Type`, `Title`, `Required`, `MaxLength`,
`MinLength`, `TextHelp`, `values` (mapa valor→etiqueta), `Tabla` (catálogo relacionado),
`Multiple`, `Route` y `Extensiones` (para `Document`/`Image`), `CanEditOnCreate`, `CanEditOnUpdate`.

Los cuatro tipos `RegistrationUser`/`RegistrationDate`/`ModificationUser`/`ModificationDate`
se rellenan solos: son la auditoría básica de cada tabla.

Acceso desde vistas y controladores:

```php
$model->Nombre->getValue();   // valor
$model->Nombre->getTitle();   // etiqueta legible (auto-derivada del nombre si no se declara)
$model->Nombre->getName();    // nombre de columna
echo $model->Nombre;          // __toString() → valor
$model->setNombre('X');       // __call → __set
```

En los formularios, el `name` del input **debe** ser `NombreDelModelo[Columna]` para que
`receiveData()` lo encuentre:

```php
<input name="<?php echo get_class($model); ?>[<?php echo $model->Nombre->getName(); ?>]" ... >
```

## 2.7 Listados: `ListaAjax` + DataTables server-side

El patrón estándar de un listado CRUD:

```php
protected function getListAjaxObject()
{
    Menu::setActive("configuracion_periodos");
    $fields     = ['Id','Nombre','FechaInicio','FechaFin','Estado']; // columnas consultadas
    $titles     = ['Nombre','Fecha de Inicio','Fecha de Fin'];       // encabezados visibles
    $fieldsShow = ['Nombre','FechaInicio','FechaFin'];               // columnas renderizadas
    $fieldsType = ['Enlace','Texto','Texto'];                        // cómo se pinta cada una

    $table = new ListaAjax($this->Module, $this->CurrentAction);
    $table->setData(NULL, $fields, $titles, $fieldsType, $fieldsShow);
    $table->setModel('PeriodosModel');
    $table->setFilters(['estado']);           // ids de <select id="filtro-estado"> en la vista
    $table->setCriteria($this->loadCriteria());
    $table->setFiltersSesion([]);             // filtros globales a aplicar
    return $table;
}
```

- `Controller::listAction()` renderiza la vista con `$listaHtml = $table->getHtml()`.
- `Controller::dataListAjaxAction()` responde el JSON paginado (`generateDataListAjax()`).
- La vista sólo necesita imprimir `<?php echo $listaHtml; ?>` y declarar los `<select id="filtro-*">`.
- `ListaAjax` genera además los botones de exportación (print/copy/excel/csv/pdf) vía DataTables Buttons.

Otros setters útiles: `addActions()`, `setPermission()`, `setState(true)` (leyenda activo/inactivo),
`addAlias($campo, $valores)`, `addTags()`, `disablePaging()`, `disableOrdering()`, `addParameter()`.

## 2.8 Vistas y layouts

`View::render_view('carpeta/vista', $parameters)`:

1. `extract($parameters)` → cada clave del array queda como variable en la vista.
2. Determina el layout instanciando de nuevo el controlador (`$controllerName`) y llamando `getLayout()`.
3. Incluye `app/layouts/<layout>.php`, que a su vez inserta menú, shortcuts, flashes, logs y el `$content`.

`$parameters` viene casi siempre de `Controller::loadMetadata()`, que aporta:
`meta` (title/description), `application`, `Menu`, `Shortcuts`, `appColor`, `controllerName`,
`currentAction`, `permission` y `subtituloSection` (el selector de periodo).

Layouts disponibles (`Config::$layout`): `metronic` (backoffice), `metronic_public` (portal),
`metronic_empty` (pantallas sin chrome: login, consolas), `empty`, `clear` (para respuestas AJAX
que devuelven HTML), `impresiones` (PDF/impresión).

Convención de carpetas de vistas: `app/views/<modulo>/{list,create,edit,view,_form}.php`.
`_form.php` se comparte entre `create` y `edit` mediante `View::load_view('modulo/_form', $parameters)`.

## 2.9 Menú

`app/config/Menu.php` define arrays estáticos:
- `Menu::$principal` → backoffice.
- `Menu::$public['estudiante'|'docente'|'director'|'lider'|'coordinador']` → portal.

Cada ítem: `Nombre` (clave para marcar activo), `Titulo`, `Controller`, `Action`, `Icono`, `SubMenus`.
Un ítem sin `Controller` es un separador de sección.

En el login, `UsuariosModel::loadMenu()` **filtra el menú por permisos**: elimina todo ítem cuyo
`ROUTER::create_action_url()` devuelva `#`. El menú resultante queda cacheado en sesión, así que
**cambiar permisos exige volver a iniciar sesión** para ver el menú actualizado.

Marcar el ítem activo desde el controlador: `Menu::setActive("configuracion_periodos");`
