# 14 — Guía de Desarrollo

> Reglas específicas para **este** proyecto, derivadas de cómo está realmente construido.
> No son buenas prácticas genéricas: son las convenciones que hay que respetar para que el código funcione y encaje.

---

## 0. Antes de escribir una línea

```
1. Identificar el módulo afectado → 04_MODULES.md
2. Leer sus reglas de negocio     → 10_BUSINESS_LOGIC.md
3. Ver su traza completa          → 21_TRACEABILITY.md
4. Abrir el controlador, el modelo y las vistas que ya existen
5. Comprobar qué estilo de FK usa la tabla (IdColaborador vs ColaboradorId)
6. Buscar si ya hay un módulo equivalente e imitarlo
```

**Módulo de referencia para un CRUD:** `CompetenciasController` (130 líneas) + `CompetenciasModel` (30 líneas) + `app/views/competencias/`. Es el más limpio y pequeño del proyecto.

---

## 1. Crear un módulo nuevo

### Orden estricto

```
1. Migración   → estructura de tabla + vista SQL
2. Modelo      → getOptionsAttributes()
3. Controlador → loadAccessControl() + acciones
4. Vistas      → list / _form / create / edit / view
5. Menú        → app/config/Menu.php
6. Semilla     → permisos por rol (+ datos iniciales si aplica)
7. Quick-search → QuickActionsConfig.php (opcional)
8. Verificar   → cerrar sesión, volver a entrar, probar el flujo
```

### 1.1 Migración

```bash
php bin/make-migration crear_tabla_mi_modulo
# → database/migrations/2026_08_28_143022_crear_tabla_mi_modulo.php
```

La plantilla generada lanza `LogicException` hasta que la implementas. Rellénala:

```php
<?php
return array(
    'up' => function (PDO $pdo): void {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS mi_modulo (
                Id INT AUTO_INCREMENT PRIMARY KEY,
                Nombre VARCHAR(150) NOT NULL,
                ColaboradorId INT NULL,
                Estado TINYINT(1) NOT NULL DEFAULT 1,
                CreatedAt DATETIME NULL,
                UpdatedAt DATETIME NULL,
                INDEX idx_mi_modulo_colaborador (ColaboradorId),
                INDEX idx_mi_modulo_estado (Estado),
                CONSTRAINT fk_mi_modulo_colaborador
                    FOREIGN KEY (ColaboradorId) REFERENCES colaboradores(Id)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
        ");

        // Si el modelo va a leer de una vista, créala AQUÍ
        $pdo->exec("
            CREATE OR REPLACE VIEW vista_mi_modulo AS
            SELECT m.*, TRIM(CONCAT(IFNULL(c.Nombres,''),' ',IFNULL(c.Apellidos,''))) AS NombreColaborador
            FROM mi_modulo m
            LEFT JOIN colaboradores c ON c.Id = m.ColaboradorId
        ");
    },
    'down' => function (PDO $pdo): void {
        $pdo->exec("DROP VIEW IF EXISTS vista_mi_modulo");
        $pdo->exec("DROP TABLE IF EXISTS mi_modulo");
    },
);
```

**Reglas de migración:**

| # | Regla |
|---|---|
| 1 | Nombre `YYYY_MM_DD_HHMMSS_descripcion_snake_case.php`. Lo genera `bin/make-migration` |
| 2 | El fichero **retorna un array** con las closures `up(PDO)` y `down(PDO)` |
| 3 | `up` debe ser **idempotente** (`IF NOT EXISTS`, `CREATE OR REPLACE VIEW`) |
| 4 | `down` debe deshacer exactamente lo que hizo `up` |
| 5 | ⛔ **Nunca edites ni renombres una migración ya aplicada.** Corrige con una nueva |
| 6 | ⛔ Los datos de catálogo y demo van en **semillas**, no en migraciones |
| 7 | Si cambias una tabla con vista, **recrea la vista en la misma migración** |
| 8 | `ENGINE=InnoDB`, `utf8mb4`, `utf8mb4_0900_ai_ci` |

```bash
php bin/migrate validate   # revisión estática, sin conectar
php bin/migrate status
php bin/migrate up
php bin/migrate down       # revierte el último lote
```

### 1.2 Modelo

`app/models/MiModuloModel.php`:

```php
<?php

class MiModuloModel extends Model
{
    protected static $TABLE_NAME = 'mi_modulo';
    protected static $VIEW_NAME  = 'vista_mi_modulo';

    // Estados del flujo, SIEMPRE como constantes
    const ESTADO_BORRADOR = 'borrador';
    const ESTADO_ACTIVO   = 'activo';

    public static function getOptionsAttributes()
    {
        return array(
            array('Type' => 'AutoincrementId', 'Name' => 'Id'),
            array('Type' => 'text',     'Name' => 'Nombre', 'Title' => 'Nombre',
                  'Required' => true, 'MaxLength' => 150),
            array('Type' => 'select',   'Name' => 'ColaboradorId', 'Title' => 'Colaborador',
                  'Table' => 'colaboradores'),
            array('Type' => 'checkbox', 'Name' => 'Estado'),
        );
    }

    public static function model($className = __CLASS__)
    {
        return parent::model($className);
    }

    /** Validación de negocio: contrato ['ok' => bool, 'errores' => string[]] */
    public static function validarAlta($nombre, $colaboradorId)
    {
        $errores = array();
        if (trim((string)$nombre) === '') {
            $errores[] = 'El nombre es obligatorio.';
        }
        if ((int)$colaboradorId <= 0) {
            $errores[] = 'Debes seleccionar un colaborador.';
        }
        return array('ok' => empty($errores), 'errores' => $errores);
    }
}
```

**Reglas de modelo:**

| # | Regla |
|---|---|
| 1 | Nombre `{Entidad}Model`, fichero homónimo en `app/models/`. **Sin namespace** |
| 2 | `$TABLE_NAME` y `$VIEW_NAME` obligatorios (`$VIEW_NAME` puede apuntar a la propia tabla) |
| 3 | `getOptionsAttributes()` describe **todas** las columnas que el modelo va a leer o escribir |
| 4 | Sobrescribe `model()` con `return parent::model($className)` — lo exige el patrón de instancia estática |
| 5 | Los estados van en **constantes** `ESTADO_*`, nunca literales sueltos |
| 6 | La lógica de negocio va en **métodos estáticos** del modelo |
| 7 | Activa `$LOG = true` solo si de verdad necesitas auditoría con diff (tiene coste) |
| 8 | ⛔ No hagas `echo` ni construyas HTML en un modelo |

### 1.3 Controlador

`app/controllers/MiModuloController.php`:

```php
<?php

class MiModuloController extends Controller
{
    public static $TITLE_NAME  = 'Mi Módulo';
    public static $MODULE_NAME = 'MiModulo';     // ← clave en la tabla permisos
    protected $ViewFolder      = 'mi_modulo';

    protected function loadAccessControl()
    {
        $this->AccessControl = array(
            'list'         => '@',
            'view'         => '@',
            'create'       => '@',
            'edit'         => '@',
            'remove'       => '@',
            'dataListAjax' => '@',
        );
    }

    public function listAction()
    {
        Menu::setActive('mi_modulo');            // ← debe coincidir con "Nombre" en Menu.php
        parent::listAction();
    }

    public function dataListAjaxAction()
    {
        try {
            parent::dataListAjaxAction();
        } catch (Throwable $exception) {
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(array(
                'draw' => (int)($_POST['draw'] ?? 0),
                'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => array(),
                'error' => 'No fue posible cargar el listado en este momento.'
            ));
        }
    }

    public function createAction()
    {
        $this->Model = MiModuloModel::model();
        if (isset($_POST[get_class($this->Model)])) {
            if ($this->Model->save()) {
                UserFlash::setFlash('Success', 'Registro creado correctamente.');
                ROUTER::redirect_to_action($this->Module, 'list');
            }
            UserFlash::setFlash('Error', 'Ocurrió un error al crear el registro.');
            ROUTER::redirect_to_action($this->Module, 'create');
        }

        $parameters = $this->loadMetadata();
        $parameters['model']     = $this->Model;
        $parameters['csrfToken'] = Controller::generateCsrfToken();
        View::render_view($this->ViewFolder . '/create', $parameters);
    }

    public function editAction()
    {
        $this->Model = new MiModuloModel();
        if (isset($_POST[get_class($this->Model)])) {
            $this->Model->loadById($_POST[get_class($this->Model)]['Id']);   // ← imprescindible
            if ($this->Model->save()) {
                UserFlash::setFlash('Success', 'Registro editado correctamente.');
                ROUTER::redirect_to_action($this->Module, 'list');
            }
            UserFlash::setFlash('Error', 'No se editó correctamente.');
            ROUTER::redirect_to_action($this->Module, 'edit',
                                       array('Id' => $this->Model->Id . ''));
        }

        if (isset($_GET['Id'])) {
            $this->Model->loadById($_GET['Id']);
            $parameters = $this->loadMetadata();
            $parameters['model']     = $this->Model;
            $parameters['csrfToken'] = Controller::generateCsrfToken();
            View::render_view($this->ViewFolder . '/edit', $parameters);
            return;
        }

        UserFlash::setFlash('Error', 'Parámetros inválidos.');
        ROUTER::redirect_to_action($this->Module, 'list');
    }

    public function viewAction()
    {
        $this->Model = new MiModuloModel();
        if (isset($_GET['Id'])) {
            $this->Model->loadById($_GET['Id']);
            $parameters = $this->loadMetadata();
            $parameters['model'] = $this->Model;
            View::render_view($this->ViewFolder . '/view', $parameters);
            return;
        }
        UserFlash::setFlash('Error', 'Parámetros inválidos.');
        ROUTER::redirect_to_action($this->Module, 'list');
    }

    public function removeAction()
    {
        // El framework ya exigió POST + CSRF en Controller::process()
        $this->Model = new MiModuloModel();
        $id = (int)($_POST['Id'] ?? $_GET['Id'] ?? 0);
        if ($id > 0) {
            $this->Model->deleteById($id);
            UserFlash::setFlash('Success', 'Registro eliminado correctamente.');
            ROUTER::redirect_to_action($this->Module, 'list');
            return;
        }
        UserFlash::setFlash('Error', 'Parámetros inválidos.');
        ROUTER::redirect_to_action($this->Module, 'list');
    }

    protected function getListAjaxObject()
    {
        $modelName  = 'MiModuloModel';
        $fields     = array('Id', 'Nombre', 'NombreColaborador', 'Estado');
        $titles     = array('Nombre', 'Colaborador');
        $fieldsShow = array('Nombre', 'NombreColaborador');
        $fieldsType = array('Enlace', 'Texto');

        $table = new ListaAjax($this->Module, $this->CurrentAction);
        $table->setData(NULL, $fields, $titles, $fieldsType, $fieldsShow);
        $table->setCheckbox(false);
        $table->setFiltersSesion(array());
        $table->setModel($modelName);
        return $table;
    }
}
```

**Reglas de controlador:**

| # | Regla |
|---|---|
| 1 | Nombre `{Nombre}Controller`, fichero homónimo. **Sin namespace** |
| 2 | Extiende `Controller` |
| 3 | **Toda acción debe estar en `loadAccessControl()`**, o es inaccesible incluso para el administrador |
| 4 | Un método `xxxAction()` sin entrada en el mapa → `ERROR_ACCESS` → redirección silenciosa |
| 5 | POST-Redirect-GET con `UserFlash` tras toda escritura |
| 6 | En `edit`, `loadById()` **antes** de `save()` |
| 7 | Siempre `$parameters = $this->loadMetadata()` antes de renderizar |
| 8 | Siempre `$parameters['csrfToken'] = Controller::generateCsrfToken()` en vistas con formulario |
| 9 | `Menu::setActive()` al principio del listado |
| 10 | ⛔ Nada de SQL literal en el controlador |
| 11 | ⛔ Nada de HTML extenso en el controlador |
| 12 | Castea **toda** entrada de `$_GET`/`$_POST` antes de meterla en un criterio |

### 1.4 Vistas

```
app/views/mi_modulo/
├── list.php
├── _form.php
├── create.php
├── edit.php
└── view.php
```

`list.php`:

```php
<div id="kt_content_container" class="container-xxl">
    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <h3 class="card-title m-0">Mi Módulo</h3>
            <?php if (($permission['create'] ?? 0) >= 1) { ?>
                <a href="<?php echo ROUTER::create_action_url($controllerName, 'create'); ?>"
                   class="btn btn-primary">Nuevo</a>
            <?php } ?>
        </div>
        <div class="card-body">
            <?php echo $listaHtml; ?>
        </div>
    </div>
</div>
```

`create.php` y `edit.php`:

```php
<div id="kt_content_container" class="container-xxl">
    <?php View::load_view('mi_modulo/_form',
        compact('controllerName', 'currentAction', 'model', 'csrfToken')); ?>
</div>
```

`_form.php`: ver la plantilla completa en [07_FRONTEND.md](07_FRONTEND.md) §3.

**Reglas de vista:**

| # | Regla |
|---|---|
| 1 | Contenedor `<div id="kt_content_container" class="container-xxl">` |
| 2 | `<input type="hidden" name="_csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">` en todo formulario |
| 3 | Campo oculto con el `Id` del modelo |
| 4 | Nombres de input **`<?= get_class($model) ?>[<?= $model->Campo->getName() ?>]`** |
| 5 | Etiquetas desde `$model->Campo->getTitle()` |
| 6 | Valores **siempre** con `htmlspecialchars()` |
| 7 | URLs siempre con `ROUTER::create_action_url()` |
| 8 | Los parciales llevan prefijo `_` |
| 9 | Condicionar los botones a `$permission['create'\|'edit'\|'remove']` |
| 10 | ⛔ Nada de `DB::getConnection()` ni SQL en una vista |
| 11 | Imita la nomenclatura del módulo que estés tocando (inglés en los antiguos, español en los nuevos) |

### 1.5 Menú

`app/config/Menu.php`, dentro de `$principal` (backoffice) o `$public` (portal):

```php
array(
    "Nombre"     => "mi_modulo",                              // ← Menu::setActive()
    "Titulo"     => "Mi Módulo",
    "Controller" => "MiModulo",
    "Action"     => "list",
    "Icono"      => '<i class="bi bi-box fs-2"></i>'
),
```

Para un grupo desplegable:

```php
array(
    "Nombre"   => "mi_grupo",
    "Titulo"   => "Mi Grupo",
    "Icono"    => '<i class="bi bi-collection fs-2"></i>',
    "SubMenus" => array( /* ítems */ )
),
```

Un ítem con solo `"Titulo"` es un separador de sección.

### 1.6 Semilla de permisos

```bash
php bin/make-seeder 026 permisos_mi_modulo permisos
```

> El primer argumento es el prefijo de orden: `0xx` base, `1xx` módulos, `2xx` demo. El script **rechaza** un prefijo ya ocupado.

```php
<?php
class PermisosMiModuloSeeder extends Seeder
{
    const TABLA = 'permisos';

    public function run()
    {
        $this->upsert(self::TABLA, array(
            array('IdRol' => 1, 'ModuleName' => 'MiModulo',
                  'Permission' => '{"create":1,"view":1,"edit":1,"remove":1,"list":1,"export":1}',
                  'Tabs' => '{}', 'Other' => ''),
            array('IdRol' => 2, 'ModuleName' => 'MiModulo',
                  'Permission' => '{"create":0,"view":1,"edit":0,"remove":0,"list":1,"export":1}',
                  'Tabs' => '{}', 'Other' => ''),
        ), array('IdRol', 'ModuleName'));
    }
}
```

```bash
php bin/seed --validate
php bin/seed PermisosMiModuloSeeder
```

### 1.7 Verificación

```
1. php bin/migrate status         → sin pendientes
2. php bin/seed --validate        → sin errores
3. Cerrar sesión y volver a entrar → el menú y los permisos se recalculan
4. Probar list / create / edit / view / remove
5. Comprobar que remove pide POST (el enlace debe ser un formulario)
6. Probar con un rol sin permiso → el ítem no debe aparecer en el menú
```

---

## 2. Añadir una acción a un módulo existente

```php
// 1. Declararla
protected function loadAccessControl()
{
    $this->AccessControl = array(
        // … existentes …
        'aprobar' => '@',       // ← sin esto, es inaccesible
    );
}

// 2. Implementarla, con guardia de método y CSRF si es una mutación
public function aprobarAction()
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST'
        || !Controller::validateCsrfToken($_POST['_csrf_token'] ?? null)) {
        UserFlash::setFlash('Error', 'Token de seguridad inválido.');
        ROUTER::redirect_to_action($this->Module, 'list');
        return;
    }

    $id = (int)($_POST['Id'] ?? 0);
    if ($id <= 0) {
        UserFlash::setFlash('Error', 'Parámetros inválidos.');
        ROUTER::redirect_to_action($this->Module, 'list');
        return;
    }

    $validacion = MiModuloModel::validarAprobacion($id);
    if (empty($validacion['ok'])) {
        foreach ($validacion['errores'] as $error) {
            UserFlash::setFlash('Error', $error);
        }
        ROUTER::redirect_to_action($this->Module, 'list');
        return;
    }

    MiModuloModel::editFromParameters(
        array('Estado' => MiModuloModel::ESTADO_APROBADO,
              'AprobadoPor' => UsuariosModel::getUserId(),
              'FechaAprobacion' => date('Y-m-d H:i:s')),
        array('WHERE' => array(array('name' => 'Id', 'value' => $id))),
        array()                                    // ← sin filtros de sesión
    );

    // 3. Registrar en el historial del módulo (patrón T5)
    MiModuloHistorialModel::model()->createFromParameters(array(
        'RegistroId'    => $id,
        'Accion'        => 'APROBACION',
        'ValorAnterior' => MiModuloModel::ESTADO_EN_REVISION,
        'ValorNuevo'    => MiModuloModel::ESTADO_APROBADO,
        'UsuarioId'     => UsuariosModel::getUserId(),
        'CreatedAt'     => date('Y-m-d H:i:s'),
    ));

    UserFlash::setFlash('Success', 'Registro aprobado correctamente.');
    ROUTER::redirect_to_action($this->Module, 'list');
}

// 4. Añadir el permiso en la BD (semilla o UPDATE del JSON de la fila de permisos)
```

**Si la acción también debe estar disponible en el portal del colaborador**, añade la clase y la acción a `Controller::$PUBLIC_COLLABORATOR_ROUTES` en `core/Controller.php`.

---

## 3. Modificar una tabla existente

```
1. Comprobar si la tabla tiene vista `vista_<tabla>`
2. php bin/make-migration agregar_columna_x_a_<tabla>
3. En up():  ALTER TABLE … ADD COLUMN …
             CREATE OR REPLACE VIEW vista_<tabla> AS …   ← si tiene vista
4. En down(): revertir ambas cosas
5. Añadir la columna a getOptionsAttributes() del modelo
6. Añadir el campo al _form.php si es editable
7. Añadirlo a $fields / $titles / $fieldsShow / $fieldsType si debe salir en el listado
8. php bin/migrate up
9. Buscar consultas afectadas:  grep -rn "<tabla>" app/
```

**⚠️ Comprueba el estilo de FK antes de nombrar una columna nueva:**
- Módulos antiguos (metas, evaluaciones, planes de desarrollo, vacaciones, competencias): **`IdColaborador`**
- Módulos nuevos (asistencia, beneficios, adelantos, capacitación, tickets, carrera, sucesión, nómina, comunicación): **`ColaboradorId`**

Usa el mismo estilo que ya tenga la tabla. **No unifiques sin una migración planificada.**

**⚠️ Si la tabla tiene semilla**, registra su clave de negocio en `database/seeders/support/ClavesDeNegocio.php` y garantiza que exista el índice único correspondiente — `tests/ClavesDeNegocioTest` lo verifica.

---

## 4. Crear un endpoint JSON

Ver [08_API.md](08_API.md) §7 para el patrón completo. Resumen:

```php
'miAjax' => '@',   // 1. declararlo en loadAccessControl()

public function miAjaxAction()
{
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Metodo no permitido.'), 405);
    }
    if (!Controller::validateCsrfTokenNoRotate($_POST['_csrf_token'] ?? null)) {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Token invalido.'), 403);
    }
    $id = (int)($_POST['Id'] ?? 0);          // 2. castear SIEMPRE
    if ($id <= 0) {
        return $this->jsonResponse(array('ok' => false, 'message' => 'Parametros invalidos.'), 422);
    }
    return $this->jsonResponse(array('ok' => true, 'data' => MiModeloModel::getById($id)));
}

private function jsonResponse(array $payload, $status = 200)
{
    http_response_code((int)$status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($payload, JSON_UNESCAPED_UNICODE);
    return;
}
```

`validateCsrfTokenNoRotate()` si la página puede llamarlo varias veces; `validateCsrfToken()` si es de una sola vez.

---

## 5. Escribir una consulta

### Con criterios (lo normal)

```php
$criteria = array(
    'WHERE' => array(
        array('name' => 'ColaboradorId', 'value' => (int)$idColaborador),
        array('name' => 'Estado', 'operator' => 'IN', 'separatorValues' => '',
              'value' => "('activo','en_revision')"),
        array('name' => 'FechaSolicitud', 'operator' => '>=', 'value' => $desde),
    ),
    'ORDER_BY' => array('COLUMN' => array('Id'), 'ORDEN' => array('DESC')),
    'LIMIT'    => array('START' => 0, 'END' => 50),
);
$filas = MiModeloModel::getAll(array('*'), $criteria, array());
//                                                    ^^^^^^^^ sin filtros de sesión
```

### 🔴 Reglas de seguridad no negociables

| # | Regla |
|---|---|
| 1 | **`criteriaToSql()` NO escapa los valores.** Todo lo que venga de `$_GET`/`$_POST` debe pasar por `(int)`, `(float)` o una limpieza con lista blanca **antes** de entrar en un criterio |
| 2 | Para valores de texto, usa un `cleanText()` con lista blanca (hay uno duplicado en 21 controladores; copia el del módulo que estés tocando) |
| 3 | Para un `IN (...)` con valores dinámicos, construye la cadena **solo a partir de valores casteados**: `"('" . implode("','", array_map('intval', $ids)) . "')"` |
| 4 | ⛔ Nunca interpoles una variable de petición en un `queryAllSql()` |
| 5 | Los nombres de columna y tabla **nunca** deben venir de la petición |

### El tercer argumento (`$filtersGeneral`)

```php
MiModeloModel::getAll($fields, $criteria);                  // TODOS los filtros de sesión
MiModeloModel::getAll($fields, $criteria, array('*'));      // idéntico al anterior
MiModeloModel::getAll($fields, $criteria, array('Estado')); // solo el filtro Estado
MiModeloModel::getAll($fields, $criteria, array());         // NINGUNO ← lo habitual
```

> ⚠️ Olvidarlo es la causa más frecuente de "no aparecen datos que sí están en la tabla".

---

## 6. Convenciones de nomenclatura

| Elemento | Convención | Ejemplo |
|---|---|---|
| Controlador | `PascalCase` + `Controller` | `BeneficiosSolicitudesController` |
| Modelo | `PascalCase` + `Model` | `BeneficiosSolicitudesModel` |
| Servicio | `PascalCase` + `Service` | `TalentScoreService` |
| Tabla | `snake_case` plural | `beneficios_solicitudes` |
| Vista SQL | `vista_` + tabla | `vista_beneficios_solicitudes` |
| Columna | `PascalCase` | `FechaSolicitud`, `EstadoSolicitud` |
| FK | `IdEntidad` (antiguos) / `EntidadId` (nuevos) | Comprobar la tabla |
| Carpeta de vistas | `snake_case` | `beneficios_solicitudes/` |
| Acción | `camelCase` + `Action` | `cambiarEstadoAction()` |
| Constante de estado | `ESTADO_MAYUSCULA` con valor `snake_case` minúscula | `const ESTADO_EN_REVISION = 'en_revision'` |
| Parcial de vista | Prefijo `_` | `_form.php` |
| Migración | `YYYY_MM_DD_HHMMSS_snake_case.php` | |
| Semilla | `NNN_PascalCaseSeeder.php` | `120_NominaSeeder.php` |
| Ítem de menú (`Nombre`) | `snake_case` | `beneficios_solicitudes_admin` |

**Estilo de código PHP observado:**
- `array()` en lugar de `[]` (mayoritario, aunque hay `[]` en el código nuevo)
- Sin namespaces
- Sin `declare(strict_types=1)`
- Tipado de parámetros y retorno solo en el código más reciente
- Indentación de 4 espacios
- Comentarios y mensajes de usuario **en español**

---

## 7. Reutiliza antes de crear

### Helpers ya disponibles (`core/helpers/`)

| Helper | Para |
|---|---|
| `DateHelper` | Fechas |
| `TimeHelper` | Horas y duraciones |
| `TextHelper` | Texto |
| `NumberHelper` | Números |
| `MoneyHelper` | Moneda |
| `PassHelper` | Contraseñas (🔴 MD5) |
| `QuitarTildesHelper` | Normalizar acentos |
| `KHtml` | HTML |
| `KleePicture`, `PHPImage` | Imágenes |
| `DebugHelper` | Depuración |

### Servicios ya disponibles

| Servicio | Para |
|---|---|
| `TalentScoreService` | Cualquier cálculo de talento, desempeño o 9-Box |
| `CalculadorProgresoPlanService` | Progreso de planes de desarrollo |
| `ScoringCandidatosService` | Puntuación de candidatos |
| `ContratacionDesdeOfertaService` | Convertir oferta en colaborador |
| `NominaCalculoModel` | Liquidación de nómina |

### Componentes del core

| Componente | Para |
|---|---|
| `ListaAjax` | **Cualquier** listado paginado. No escribas tablas a mano |
| `UserFlash` | Mensajes al usuario |
| `Logger` / `LoggerManager` | Registro |
| `Cache` | Caché con TTL y namespaces |
| `RateLimiter` | Limitación de intentos |
| `PasswordPolicy` | Validación de contraseñas |
| `QueueCss` / `QueueScripts` | Assets por página |
| `BitacoraAuditoriaModel` | Auditoría de dominio |

**Antes de escribir un método nuevo:**
```bash
grep -rn "nombreDeLaFuncion" --include="*.php" app core services
```

---

## 8. Qué NO hacer

| ⛔ | Por qué |
|---|---|
| Crear una acción sin declararla en `loadAccessControl()` | Queda inaccesible; parecerá que "no funciona" |
| Redeclarar en `ConfigEnv.php` una propiedad estática que gestione el `.env` | PHP crea un almacenamiento separado y el `.env` se ignora **en silencio** |
| Escribir SQL en un controlador o en una vista | Rompe la separación de capas |
| Interpolar entrada de usuario en un criterio sin castear | Inyección SQL: `criteriaToSql()` no escapa |
| Editar o renombrar una migración ya aplicada | Rompe el historial de despliegues |
| Poner datos de catálogo en una migración | Van en semillas |
| Cambiar el contrato `?c=&a=` | Depende de él el menú, los permisos, DataTables, quick-search y las 291 vistas |
| Introducir namespaces en el código propio | El autoload es por nombre de fichero |
| Usar `[]` para inicializar arrays en código antiguo | Rompe la coherencia; imita lo que ya hay |
| Renombrar `IdColaborador` ↔ `ColaboradorId` sin migración | 20+ tablas y decenas de consultas dependen del nombre actual |
| Modificar una tabla con vista sin recrear la vista | Los modelos leen de la vista; quedaría obsoleta |
| Crear un `cleanText()` número 22 | Ya está duplicado 21 veces; usa el del módulo o promuévelo al core |
| Añadir un `eval()` | Ya hay uno en `NominaCalculoModel` y es un riesgo conocido |
| Usar `session_destroy()` en logout sin revisar | El logout actual solo hace `unset($_SESSION[APP_ID])`; cambiarlo puede afectar a `$_SESSION['public']` |
| Confiar en `CSRF_STRICT`, `IP_BLOCKING`, `NO_COPY`, `CAMBIO_ROL`, `LOGIN_AUTOMATICO` o `MAIL_DIARIOS_MAX` | 🔴 **Ninguna tiene efecto** en el código actual |
| Poner `APP_DEBUG=true` en producción | Vuelca cada SQL al navegador y activa el manejador de errores |
| Poner `DEMO_MODE_ENABLED=true` en producción | Siembra datos en la base real y relaja la autorización |
| Poner `DIRECTORIO_ACTIVO=true` | `DirectorioActivoModel` no existe → error fatal |
| "Arreglar" `SincronizacionController`, `AjaxController::getAll` o `AlertasController` creando las clases que faltan | Es legado de un producto de evaluación docente. Casi siempre lo correcto es **eliminar** |

---

## 9. Pruebas

```bash
composer test                       # vendor/bin/phpunit --colors=always tests
vendor/bin/phpunit tests/MigratorTest.php
```

**13 ficheros de prueba.** Son de **contrato y análisis estático**, no de lógica de negocio:

| Fichero | Verifica |
|---|---|
| `AdminLoginViewTest` | El login preserva CSRF y rate limiting |
| `ClavesDeNegocioTest` | Cada clave de negocio corresponde a un índice único real |
| `DatabaseStructureAuditorTest` | Migraciones y semillas cumplen el contrato mínimo |
| `DemoReadinessVerifierTest` | Hay cobertura de datos demo para cada módulo |
| `Evaluacion360SeederSafetyTest` | La semilla no borra datos ni usa rangos reservados; nadie redeclara `upsert()` |
| `LayoutConsistencyTest` | Ningún controlador usa layouts `developr`; los imprimibles no llevan chrome |
| `MigratorTest` | El contrato de migración estándar carga sin construir el Migrator |
| `MutationSecurityRegressionTest` | `remove` exige POST+CSRF; las importaciones usan CSRF; el perfil valida propiedad; los endpoints de firma exigen CSRF+admin; `AjaxController` restringe la entrada dinámica |
| `PublicCollaboratorHomeViewTest` | El dashboard tiene respaldos seguros; los iconos existen; los tipos de contrato siguen siendo administrativos |
| `PublicCollaboratorLoginViewTest` | El login del portal prepara los periodos visibles |
| `SeederPipelineTest` | El pipeline informa fallos requeridos y omisiones opcionales |
| `SQLGeneratorTest` | El asistente IA acepta SELECT seguros y rechaza SQL inseguro o costoso |

**Si tu cambio toca alguno de estos contratos, la prueba correspondiente fallará. Eso es lo que se espera.**

> No hay `phpunit.xml` en el repositorio → `[NO DETERMINADO EN EL CÓDIGO]` la configuración de bootstrap.

---

## 10. Antes de dar por terminado un cambio

- [ ] La acción está declarada en `loadAccessControl()`
- [ ] Las mutaciones exigen POST y validan CSRF
- [ ] Toda entrada de petición está casteada o limpiada antes de tocar SQL
- [ ] `loadById()` antes de `save()` en las ediciones
- [ ] Los estados usan constantes, no literales
- [ ] El cambio de estado se registra en la tabla `*_historial` del módulo
- [ ] `$parameters = $this->loadMetadata()` antes de renderizar
- [ ] Los valores en las vistas pasan por `htmlspecialchars()`
- [ ] Los permisos existen en la tabla `permisos` para los roles que deban usarlo
- [ ] Si tocaste una tabla con vista, la vista está recreada
- [ ] Si tocaste `Menu.php` o `permisos`, has cerrado sesión y vuelto a entrar para verificarlo
- [ ] `php bin/migrate status` sin pendientes
- [ ] `php bin/seed --validate` sin errores
- [ ] `composer test` sin regresiones
- [ ] Si el cambio afecta a un flujo con varias escrituras, evaluar si hace falta una transacción
- [ ] Documentación de `docs/knowledge/` actualizada

---

## Documentos relacionados
- [02_ARCHITECTURE.md](02_ARCHITECTURE.md)
- [06_BACKEND.md](06_BACKEND.md)
- [07_FRONTEND.md](07_FRONTEND.md)
- [17_AI_CONTEXT.md](17_AI_CONTEXT.md)
