# 08 — Guía de desarrollo

## 8.1 Convenciones del proyecto

| Elemento | Convención | Ejemplo |
|---|---|---|
| Controlador | `<Modulo>Controller` en `app/controllers/` | `PeriodosController` |
| Modelo | `<Entidad>Model` en `app/models/` | `PeriodosModel` |
| Vistas | `app/views/<ViewFolder>/{list,create,edit,view,_form}.php` | `app/views/periodos/` |
| Módulo en URL | `$MODULE_NAME` (camelCase si compuesto) | `?c=tipoLaborDocente&a=list` |
| Acción | método `<accion>Action()` | `listAction()` |
| Columnas BD | `PascalCase` | `FechaRegistro`, `IdPeriodo` |
| Clave de permisos | `ucfirst($MODULE_NAME)` | módulo `periodos` → permiso `Periodos` |
| Idioma | Código y comentarios en español; sin acentos en identificadores | |

Estilo: llaves en línea nueva, `array()` predominante sobre `[]`, indentación de 4 espacios,
`else`/`elseif` en línea propia. No hay linter, formateador ni suite de tests configurados.

## 8.2 Receta: crear un módulo CRUD completo

Toma `PeriodosController` + `PeriodosModel` + `app/views/periodos/` como plantilla.

### 1. La tabla

Créala directamente en MySQL. Incluye `Id` autoincremental, `Estado`, y las cuatro columnas de
auditoría (`UsuarioRegistro`, `FechaRegistro`, `UsuarioModificacion`, `FechaModificacion`).
Si vas a necesitar joins, crea también la `vista_<tabla>`.

### 2. El modelo

```php
<?php
class EjemplosModel extends Model
{
    protected static $TABLE_NAME = 'ejemplos';
    protected static $VIEW_NAME  = 'vista_ejemplos';
    protected static $LOG = true;   // audita en log_modules

    public static function getOptionsAttributes()
    {
        $Attributes = array();
        $Attributes[] = array('Type' => 'AutoincrementId', 'Name' => 'Id');
        $Attributes[] = array('Type' => 'text',   'Name' => 'Nombre', 'Required' => true, 'MaxLength' => 150);
        $Attributes[] = array('Type' => 'select', 'Name' => 'IdPeriodo', 'Title' => 'Periodo');
        $Attributes[] = array('Type' => 'checkbox','Name' => 'Estado', 'Switch' => true,
                              'values' => array(1 => 'Activo', 0 => 'Inactivo'));
        $Attributes[] = array('Type' => 'registrationUser', 'Name' => 'UsuarioRegistro',    'Title' => 'Registrado por');
        $Attributes[] = array('Type' => 'registrationDate', 'Name' => 'FechaRegistro',      'Title' => 'Fecha de registro');
        $Attributes[] = array('Type' => 'modificationUser', 'Name' => 'UsuarioModificacion','Title' => 'Modificado por');
        $Attributes[] = array('Type' => 'modificationDate', 'Name' => 'FechaModificacion',  'Title' => 'Fecha de modificación');
        return $Attributes;
    }

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

**El orden y el nombre de los atributos deben coincidir con las columnas reales**: `loadById()`
consulta exactamente `namesAttributes()`. Un atributo declarado que no exista en la tabla rompe
todas las lecturas del modelo.

### 3. El controlador

Copia el esqueleto de `PeriodosController`:

- `$TITLE_NAME`, `$MODULE_NAME`, `$ViewFolder`.
- `loadAccessControl()` con `create`, `edit`, `remove`, `view`, `list`, `dataListAjax`.
- `createAction()` / `editAction()`: `new Modelo`, `if (isset($_POST[get_class($this->Model)]))`
  → `loadById` (solo en edit) → `save()` → flash + redirect.
- `removeAction()`: `deleteById($_GET['Id'])`.
- `viewAction()`: `loadById($_GET['Id'])` + render.
- `loadCriteria()`: traduce los filtros de la vista a `criteria`.
- `getListAjaxObject()`: configura `ListaAjax` (ver [02.7](02-arquitectura.md#27-listados-listaajax--datatables-server-side)).

> No definas `listAction()` ni `dataListAjaxAction()`: los hereda `Controller` y funcionan
> automáticamente con lo que devuelva `getListAjaxObject()`.

### 4. Las vistas

- `list.php` — cabecera + panel de filtros (`<select id="filtro-estado">`) + `<?php echo $listaHtml; ?>`.
- `_form.php` — el formulario, con `action = ROUTER::create_action_url($controllerName, $currentAction)`,
  un hidden con el `Id` y cada input nombrado `<?php echo get_class($model); ?>[Campo]`.
- `create.php` / `edit.php` — casi vacíos: `View::load_view('ejemplos/_form', $parameters);`.
- `view.php` — solo lectura.

### 5. Menú y permisos

- Añade el ítem a `Menu::$principal` (o al menú público que corresponda) en `app/config/Menu.php`.
- Crea/edita el permiso del rol desde el módulo **Permisos** del backoffice, o inserta la fila en
  `permisos` con `ModuleName = 'Ejemplos'` y `Permission = {"create":1,"view":1,"edit":1,"remove":1,"list":1,"export":1}`.
- **Cierra la sesión y vuelve a entrar** para que el menú cacheado se regenere.

## 8.3 Recetas cortas

**Cargar un script o CSS solo en una pantalla**
```php
QueueScripts::pushAfter("https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js");
QueueCss::pushBefore("...");
```

**Mensaje al usuario**
```php
UserFlash::setFlash('Success', 'Guardado correctamente');   // o 'Error', 'Warning'
```

**Redirigir**
```php
ROUTER::redirect_to_action($this->Module, 'list', array('Id' => 5));
```

**Enlace que respeta permisos**
```php
<a href="<?php echo ROUTER::create_action_url('periodos', 'edit', array('Id' => $id)); ?>">Editar</a>
```

**Consulta con filtros y orden**
```php
$criteria = array(
    'WHERE' => array(
        array('name' => 'IdPeriodo', 'value' => PeriodosModel::getSesionId()),
        array('name' => 'Estado', 'value' => 1),
    ),
    'ORDER_BY' => array('COLUMN' => array('Nombre'), 'ORDEN' => array('ASC')),
);
$filas = EjemplosModel::getAllView(array('*'), $criteria, array());   // array() = sin filtros de sesión
```

**Devolver JSON desde una acción** (`$MyLayout = 'empty'` en el controlador)
```php
header('Content-Type: application/json; charset=utf-8');
echo json_encode($datos);
```

**Devolver HTML parcial para un modal**
```php
echo View::stream_view('home/seleccion_periodo_modal', $parameters, 'clear', 'home');
```

**Depurar**
```php
Config::$debug = true;              // en ConfigEnv.php: activa ErrorHandler y log de SQL
LogsConsole::setLog('DB', $sql);    // aparece en el pie de página
DebugModel::registrar($cualquierCosa);  // fila en la tabla debug
```

## 8.4 Trampas conocidas (lee esto antes de depurar tres horas)

1. **La acción redirige al login sin explicación.** Falta la entrada en `loadAccessControl()`,
   falta el método `<accion>Action()`, o el rol no tiene el permiso. `validateAccess()` no dice cuál.
2. **`save()` borra los datos.** `save()` repuebla el modelo desde `$_POST`. En procesos sin
   formulario usa `saveOnly('create')` / `saveOnly()`.
3. **Un `getAll()` no devuelve nada aunque los datos existen.** Se está aplicando el filtro de
   sesión de `Periodo`/`Estado`. Pasa `array()` como tercer parámetro.
4. **`FIND_IN_SET` en vez de `=`.** El filtro de sesión genera `FIND_IN_SET('29', IdPeriodo)`.
   Si la columna es numérica y el valor no coincide exactamente como texto, no cruza.
5. **El menú no cambia tras editar permisos.** Está cacheado en `$_SESSION[...]['Menu']` desde el
   login. Cerrar sesión.
6. **Añadiste una columna a la tabla y todo dejó de funcionar.** Añádela también a
   `getOptionsAttributes()` (y al revés): `loadById()` pide exactamente los atributos declarados.
7. **`IdDocente` en evaluaciones de pares es el evaluador, no el evaluado.** El evaluado es
   `IdDocentePar` (y `0` significa autoevaluación).
8. **No hay transacciones.** Los procesos masivos (consolidación, sincronización, generación de
   ODS) fallan a medias y dejan estado inconsistente. Piensa en idempotencia, no en rollback.
9. **`echo` dentro de la lógica.** Varios métodos del core imprimen directamente
   (`Model::onlyValidate()`, `Controller::CrearCarpetas()`, el `catch` de `AutoLoad.php`).
   Si una respuesta JSON sale corrupta, busca un `echo` perdido.
10. **`ob_start()` global.** Toda la salida está bufferizada; un `exit()` sin `ob_flush()` puede
    tragarse la respuesta.
11. **Vistas SQL desincronizadas entre entornos.** No están versionadas. Si tu cambio depende de
    una vista nueva o modificada, documenta el DDL en el PR.
12. **`ROUTER::create_action_url()` instancia el controlador destino.** Constructores caros o con
    efectos secundarios se ejecutan al pintar cada enlace.
13. **Warnings al cambiar de periodo.** `Controller::changeFiltersSesionAction()` recorre
    `$filtersSesion[$key]['subFilters']`, clave que `Config::$FILTERS_SESION` no define. El cambio
    surte efecto igualmente, pero ensucia el log. Si añades un filtro de sesión nuevo, decláralo
    con `'subFilters' => array()` para evitarlo.

## 8.5 Entorno local

```bash
composer install                     # vendor/ no está versionado
cp app/config/ConfigEnv.example.php app/config/ConfigEnv.php   # y rellenar (ver plantilla en 09)
cp app/config/DataEnv.example.php  app/config/DataEnv.php
# Copiar la plantilla Metronic a public/metronic_v8.0.23_html_demo1/
# Restaurar un dump de la base evaluacion_docente (tablas + vistas)
php -S localhost:8000 -t public      # portal:      http://localhost:8000/
                                     # backoffice:  http://localhost:8000/admin/
```

Requisitos: PHP con `pdo_mysql`, `curl`, `mbstring`, `gd` (imágenes de firma), `zip` (mPDF).
El SSO de Microsoft no funcionará en local (`redirect_uri` fijado a producción): usa
`LOGIN_MANUAL = true` y entra por `?c=public&a=loginManual` o por `/admin/?c=login&a=klee`.

## 8.6 Flujo de trabajo git

- Rama principal: `master`. Cada desarrollador trabaja en su rama nominal (`yeison`, `yesid`,
  `edwin`, `developer`…) y hay ramas por cliente (`EAN`, `EanDEmo`).
- Mensajes de commit en español, imperativo/participio corto:
  `agregado reporte de ods a lideres de proceso`, `ajuste de servicio Ollama`.
- **No commitear**: `app/config/ConfigEnv.php`, `app/config/DataEnv.php`, `vendor/`,
  `public/metronic*/`, `files/configuraciones/`, `files/usuarios/`, `.idea/`.
