# AGENTS.md — Sistema Integral de Proveeduría Municipal

Memoria técnica del proyecto. **Léala antes de tocar código**: aquí están las decisiones que
no se deducen del código y que romperían el sistema si se ignoran.

## Qué es

ERP municipal para proveedores, departamentos, partidas presupuestales, órdenes de compra y
vales de salida. PHP 8.2+ / MySQL 8 (MariaDB 10.4+) / JavaScript ES6+ sin build step.
Instalador web en `/install`. Documentos oficiales en PDF con Dompdf.

Ruta del proyecto: `D:\Users\oscar\Documents\proveeduria\minimax`

## Entorno de desarrollo

```bash
# PHP (no está en el PATH)
C:\xampp\php\php.exe

# Composer (phar con nombre sin extensión)
C:\xampp\php\php.exe C:\xampp\composer\composer install

# MySQL/MariaDB local (XAMPP), sin servicio: hay que arrancarlo
C:\xampp\mysql\bin\mysqld.exe --defaults-file=C:\xampp\mysql\bin\my.ini
C:\xampp\mysql\bin\mysql.exe -u root --protocol=tcp   # contraseña vacía

# Servidor de desarrollo
C:\xampp\php\php.exe -S 127.0.0.1:8080 -t public public/index.php
```

- Base de desarrollo: `proveeduria_test` · Base de pruebas: `proveeduria_unittest`.
- `ext-gd` se habilitó editando `C:\xampp\php\php.ini` (copia de seguridad en `php.ini.bak-mavis`).
- Instalar dependencias nuevas: `dompdf/dompdf ^3.1.6` (la 2.x está bloqueada por advisories),
  `phpunit/phpunit ^10.5`.

## Reglas que NO se negocian

1. **Folio**: `SequenceService` con `SELECT … FOR UPDATE`. Jamás `MAX(folio) + 1`.
2. **Dinero**: solo `App\Support\Money` (enteros escalados a 10⁻⁴). Nunca `float` en cálculos.
3. **IVA redondeado POR LÍNEA**, half-up. La suma por línea puede diferir en centavos del
   cálculo global: eso es intencional.
4. **El backend recalcula siempre** los importes. El cálculo del navegador es informativo.
5. **Borrador no descuenta**; emitir descuenta; cancelar devuelve (movimiento inverso).
6. **Emisión y cancelación son transaccionales.** Un fallo no deja registros parciales.
7. **Ampliaciones y reducciones** se registran como movimientos: nunca se sobrescribe el
   presupuesto autorizado en silencio.
8. **Los registros con dependencias se desactivan, no se borran** (proveedores, usuarios,
   departamentos).
9. **El 5 al Millar es una característica, no un descuento.** No aplicar deducciones automáticas.
10. **La Opinión 32-D es cumplimiento documental**, con vigencia. No inventar obligaciones legales.
11. **Todo cambio relevante se audita** con `Audit::log()`.
12. **Un rol del sistema no se elimina nunca, y un rol con personas asignadas tampoco**: se
    dejaría a esas personas sin acceso. Los roles propios (`is_system = 0`) sí se pueden eliminar.
13. **Las partidas presupuestales son un catálogo global**: `pv_budget_items` NO tiene
    `department_id`. La partida pertenece a la administración y se identifica por
    **(ejercicio fiscal, número de partida)**. El departamento de una orden es su **centro de
    costo**, no el dueño del presupuesto. Ver `docs/PARTIDAS-PRESUPUESTALES.md`.
14. **Solo las partidas genéricas (código de 3 dígitos) se pueden asignar a una orden y admiten
    movimientos.** Un capítulo o un concepto es un agrupador: sus saldos son la **suma de sus
    partidas hijas**, no una bolsa propia. Ampliar o reducir un capítulo no tiene sentido: el
    servicio lo rechaza con 422.
15. **La importación masiva es atómica.** Si un renglón del archivo tiene error, no se aplica
    ninguno: se corrige el archivo y se vuelve a subir. Además, la revisión previa no escribe
    nada y el lote se revalida dentro de la transacción al aplicarlo. Ver
    `docs/ARQUITECTURA.md` §13.
16. **Un requerimiento de compra es una solicitud, no una orden.** No lleva proveedor, ni partida,
    ni precios, y **no mueve presupuesto**: el dinero se compromete cuando la orden derivada se
    emite. El folio se reserva al **enviar** el requerimiento, no al crearlo. Ver
    `docs/ARQUITECTURA.md` §14.
17. **La documentación de la orden no es una URL pública.** Los archivos viven en
    `storage/documentos/` (fuera de la raíz web) y **no** los sirve `/media/{archivo}`, que solo
    expone `storage/uploads/`. La descarga pasa por
    `/api/ordenes/documentos/{id}/descargar`, con sesión y permiso. Adjuntar o eliminar un
    documento **no altera los importes de la orden ni el presupuesto**. Ver
    `docs/ARQUITECTURA.md` §15.
18. **Una orden sin precio unitario no se emite.** El bloqueo vive en
    `OrderWorkflowService::issue()` (422 nombrando los conceptos); el borrador sí se guarda sin
    precio. El **renglón** de un concepto tiene estado (`activo` / `cancelado`): el cancelado
    **no se elimina nunca**, no entra en los totales y **no se descuenta del presupuesto**. El
    ajuste de entrega de una orden emitida reduce cantidades o cancela productos, **devuelve a
    la partida** lo que ya no se compra (movimiento de liberación) y corrige el desglose por
    departamento **sin tocar el contador de órdenes**. El vale de salida sigue al ajuste
    **solo si no ha sido entregado**. Permiso `orders.adjust`. Ver
    `docs/ARQUITECTURA.md` §16.

## Convenciones de código (resumen; el detalle está en `docs/CONTRATO-DESARROLLO.md`)

- Todo controlador: `namespace App\Controllers;` + `use App\Core\Request;` + `use App\Core\Response;`.
  Sin el namespace el autoload carga el archivo dos veces (fatal). Sin los imports, la firma
  resuelve a `App\Controllers\Request` (TypeError).
- Rutas: un archivo por módulo en `routes/modules/`, devuelve `function (Router $router): void`.
- API: `{ok, data, meta}` / `{ok:false, error:{code, message, fields}}`. Listas con `data.items`.
- `sort` siempre contra lista blanca de columnas.
- Frontend: `window.PV` (`PV.api`, `PV.DataTable`, `PV.toast`, `PV.confirm`, `PV.SearchSelect`).
  **No** editar `public/assets/css/app.css` ni `public/assets/js/app.js` desde un módulo.
- Las etiquetas en español de módulos y acciones de permisos salen de
  `App\Support\PermissionLabels`: es la única fuente de verdad. Un permiso nuevo necesita su
  etiqueta allí o el administrador vería el nombre técnico.
- **Sistema de diseño**: los nombres de clase son estables y hay una guía normativa en
  `docs/SISTEMA-DISENO.md`. Un módulo nuevo combina clases existentes; si falta un
  componente, se añade en `app.css` (sección correspondiente) y se documenta en la guía.
- Plantillas PDF en `resources/document-templates/`. Los márgenes se aplican con
  `padding` en el `body` (Dompdf 3 no tiene `setMargins()` y `@page` no inseta las
  tablas al 100 %; ver `DocumentRenderer::applyPageMargins`).
- `View::render` propaga al layout las variables locales de la vista: una vista
  puede declarar `$scripts = ['js/x.js']` y el layout las carga.
- **Script en línea: los valores van con `json_encode()`, nunca con `View::e()`.**
  `View::e()` escapa pero no entrecomilla: `ask: <?= View::e('/api/x') ?>` produce
  `ask: /api/x` y el navegador lo interpreta como una expresión regular
  (`SyntaxError: Invalid regular expression flags`). Para texto en PHP dentro de JS,
  `json_encode()`.
- **El asistente de IA redacta; el sistema calcula.** El modelo nunca escribe SQL ni
  decide qué cifras se muestran: pide una herramienta de `AiTools`, el sistema ejecuta la
  consulta de `ReportCatalog` y compone la tabla. No romper ese reparto.
- `Settings::all()` incluye los valores secretos; las vistas deben usar
  `Settings::allPublic()`. La clave de la IA se guarda con `Settings::set(..., secret: true)`.
- `Seeder::seedSettings()` **solo crea las claves que faltan**: un ajuste existente nunca se
  sobrescribe (si se sobrescribiera, `tools/migrar.php` borraría en cada actualización la
  configuración que el administrador cambió, incluida la clave de la IA).
- **La paginación viaja en la RAÍZ de la respuesta**: `{ok, data: {items}, meta: {total, page,
  per_page, pages}}`. `PV.DataTable` lee `response.meta` y, por compatibilidad, `data.meta`. Un
  endpoint que devuelva la paginación solo dentro de `data` hace que la tabla muestre «de 25» y
  no avance: la convención mayoritaria es devolver `meta` en **ambos** sitios.

## Trampas del entorno

- **`pv_budget_items` no tiene `department_id`.** Cualquier consulta que una la partida con un
  departamento (`JOIN departments d ON d.id = bi.department_id`) revienta con «Unknown column».
  El departamento de una orden se toma de `purchase_orders.department_id`.
- **Los saldos de un capítulo o un concepto son un agregado de sus partidas hijas.** La caché de
  esas sumas (`BudgetService::$childTotals`) es estática: se invalida con
  `BudgetService::flushTotals()` tras cualquier movimiento o carga del catálogo. Al agrupar por
  capítulo, el total del capítulo **suma los conceptos** (no los conceptos y las partidas a la
  vez: eso cuenta cada peso dos veces).
- El catálogo oficial del ejercicio 2026 se extrae del PDF con
  `node tools\extraer-presupuesto.js "<ruta.pdf>"`: genera `database/data/presupuesto-2026.json`
  y **falla** si los subtotales no cuadran. `php tools\cargar-presupuesto.php` lo carga en la base
  (es idempotente) y con `--limpiar` deja la base sin partidas, órdenes ni vales.
- **El desglose por departamento de cada partida vive en `pv_budget_item_departments`** (partida ×
  departamento). Se mantiene en la MISMA transacción que el movimiento de la partida: emitir suma,
  cancelar resta, y se cumple el invariante «la suma del desglose = `budget_items.spent_amount`».
  Las órdenes emitidas son la fuente de verdad: `php tools\recalcular-gasto-departamentos.php`
  reconstruye la tabla (y `--verificar` solo compara). `orders_count` es SIN signo: el servicio
  usa dos sentencias (una para sumar, otra para restar) porque un delta negativo en un `INSERT …
  ON DUPLICATE KEY UPDATE` no vale.
- El reporte de artículos comprados usa el parámetro de filtro **`q`** (texto libre sobre la
  descripción del concepto): es el que responde «cuántas llantas rin 15 se han comprado». Solo
  cuenta órdenes **emitidas**: un borrador no es una compra.
- `SET FOREIGN_KEY_CHECKS` y `TRUNCATE` provocan **COMMIT implícito** en MySQL: no meterlos
  dentro de una transacción.
- PowerShell 5.1: `Set-Content -Encoding UTF8` mete BOM; para reescribir código usar las
  herramientas de fichero, no PowerShell.
- Dompdf con `isRemoteEnabled = false`: el logotipo se pasa como ruta **absoluta** del
  sistema de archivos (o como URI de datos).
- PDO con `ATTR_EMULATE_PREPARES = false` **no admite reutilizar un parámetro con nombre**
  (`HY093`): cada aparición necesita su propio marcador (`LIKE :f1 OR … LIKE :f2`).
  Hay un escáner: `php tools/buscar-marcadores-repetidos.php`.
  **Y el parámetro de más también falla**: un parámetro que la sentencia no usa lanza
  `HY093` ("parameter was not defined"). Los parámetros deben coincidir **exactamente** con
  los marcadores. Ojo: el escáner no ve el caso en que el marcador se inyecta por
  concatenación (p. ej. `$yearFilter` repetido en cinco ramas de un `UNION`): para eso
  hace falta una prueba de invariante, como la de `tests/Feature/ReportCatalogTest.php`,
  que compara los marcadores de la consulta con las claves de los parámetros.
- `app.js` se carga **sin `defer`** a propósito: define el objeto `PV` que consumen los
  scripts de cada módulo. Si se cargara diferido, los scripts de página —también diferidos—
  se ejecutarían antes y no encontrarían `PV.DataTable` ni `PV.api`.
- El servidor embebido de PHP (`php -S`) solo sirve los archivos estáticos si el enrutador
  devuelve `false`; `public/index.php` lo hace cuando el archivo existe dentro de `public/`.
- `app.base_url` guarda **solo la ruta** (no la URL completa): las URLs internas se resuelven
  contra el host actual y el sistema funciona con cualquier dominio o puerto.
- `app.asset_version` es el parámetro de caché de los assets: hay que incrementarlo
  (`config/app.php`) tras cambiar `app.css` o `app.js`, o el navegador sirve la versión vieja.
- **El identificador de una ruta se lee con la misma clave con la que está escrito.** El
  enrutador mete los parámetros de ruta en el cuerpo de la petición (`Request::withParams()`), así
  que `/api/roles/{id}/permisos` se lee con `$request->integer('id')`. Si la ruta dice `{id}` y el
  controlador lee `role_id`, el identificador llega como 0 y la operación responde 404 con un
  mensaje engañoso («El rol no existe»).
- **`PV.confirm()` resuelve `true` o `null`**, no recibe `onConfirm`. Se usa con `.then()`:
  `PV.confirm({...}).then(async (confirmed) => { if (!confirmed) return; ... })`.
- **El selector nativo de color (`input[type=color]`) no se puede escribir por script**: falla con
  `ACTION_EFFECT_MISMATCH`. En la configuración de apariencia el color va en un campo hexadecimal
  de texto (`#sidebar_color`) con el selector de color al lado (`#sidebar_color_picker`); el texto
  es la fuente de verdad.
- **El tema de la aplicación no es el del sistema operativo.** `:root` declara
  `color-scheme: light` y `html[data-theme="dark"]` declara `color-scheme: dark`. Sin eso los
  controles nativos (checkbox, radio, `select`, calendario, barras de desplazamiento) se
  dibujan con la paleta del SO y aparecen oscuros dentro de una interfaz clara.
- **El selector del tema oscuro es `html[data-theme="dark"]`, no `[data-theme="dark"]`.** El
  layout inyecta la marca institucional con `:root` (especificidad 0,1,0) en un `<style>`
  posterior a `app.css`. Con `[data-theme="dark"]` (0,1,0) empataba y ganaba el bloque del
  layout: el tema oscuro se quedaba con los colores de marca del modo claro y el botón
  principal quedaba con fondo azul marino y texto casi negro.
- **El hover del botón principal no usa la rampa `--brand-700`.** Esa rampa es oscura y en modo
  oscuro dejaba el texto sin contraste. Usa el token `--brand-primary-hover`, que en modo
  oscuro se deriva de `var(--brand-primary)` con `color-mix`.
- `Icon::render()` emite `width`/`height` por defecto (18 px): un SVG sin dimensiones se
  renderiza a 300 × 150 en el navegador.
- **La identidad llega por `View::share('identity', ...)`, no por `$GLOBALS`.**
  `$GLOBALS['identity']` nunca se define: al leerla, la vista se queda con un arreglo
  vacío y sobrescribe el valor compartido. El efecto es que los colores, el nombre y los
  logotipos que el administrador guarda **nunca se aplican** y la interfaz sigue con los
  valores por defecto.
- **La barra lateral y la tipografía son configurables** (`identity.sidebar_color`,
  `identity.font_family`, `identity.font_size`). El layout los inyecta en su bloque
  `<style>`: `--bg-sidebar` (oscurecido si es demasiado claro para el texto blanco),
  `--font-sans` y `html { font-size }` — cambiar la raíz hace que toda la escala en rem
  cambie de forma proporcional. `App\Support\Color` y `App\Support\Typography` son la
  única fuente de verdad.
- **Los archivos subidos viven en `storage/`, fuera de `public/`**: no se sirven con una URL
  estática (dan 404). Se exponen por la ruta `/media/{archivo}` (`Media::url()` y
  `MediaController`), que valida el nombre, la extensión y que el archivo esté dentro de
  `storage/uploads`. Para una imagen dentro de `public/` se usa `Media::url()` igual: la
  función decide.
- **El PDF incrusta el logotipo desde disco, no por URL.** `DocumentRenderer::logoPath()`
  usa `Media::absolutePath()`, que resuelve tanto `public/...` como `storage/uploads/...`.
  Si se arma la ruta a mano como `public/` + ruta, el logotipo guardado nunca se encuentra
  y el documento sale con el escudo por defecto.
- **La imagen de la pantalla de acceso es opcional.** `identity.login_image` guarda la ruta;
  vacía significa «muestra el panel institucional». El ajuste se crea con
  `Seeder::seedSettings()`, que `tools/migrar.php` ejecuta: sin ese paso, una clave nueva
  no existiría en la base y el formulario de configuración mostraría siempre el valor vacío.
- **Los identificadores de modelo de los proveedores de IA cambian.** `deepseek-chat` y
  `deepseek-reasoner` fueron **retirados** en julio de 2026; los modelos GPT-5 y superiores
  **rechazan** un `temperature` distinto de 1. `AiProviders` centraliza los valores vigentes y
  `AiClient` omite `temperature` para la familia GPT-5. No fijar identificadores en las vistas.
- La llamada al proveedor de IA puede tardar: `ai.timeout` (10–300 s) y `max_execution_time` de
  PHP deben ser suficientes, o el servidor cortará la petición a mitad.
- **Los modelos de razonamiento gastan tokens pensando.** Con `ai.max_tokens` corto (2 000) la
  respuesta llega truncada a mitad del JSON y el informe no se puede leer. El valor por defecto
  es 8 000, el servicio reintenta una vez con el doble si `finish_reason` es `length`, y
  `AiJson` (el lector tolerante) recupera la parte que sí se alcanzó a redactar.
- **El JSON de un modelo casi nunca es JSON válido.** Comas finales, claves sin comillas,
  comillas simples, saltos de línea dentro de las cadenas, números con separador de miles y
  respuestas cortadas. Por eso la respuesta se lee con `App\Services\AI\AiJson`, no con
  `json_decode`: el lector devuelve lo que entiende en lugar de fallar.
- **El modelo puede pedir una herramienta de dos maneras.** Con el mecanismo nativo
  (`tool_calls`) o escribiéndola como JSON en el contenido
  (`{"herramienta": "...", "argumentos": {...}}`). Ambos caminos se atienden: el segundo es
  la norma en los modelos gratuitos y en los agregadores como OpenRouter.
- **El filtro de alcance del panel es un fragmento `AND …`, no un `WHERE` completo.**
  `DashboardController::scopeClause()` devuelve « AND …» (o « AND 1 = 0» si el usuario está
  acotado a un departamento inexistente) y solo puede usarse **como sufijo de un WHERE que ya
  existe**. Las consultas que no tienen otro filtro usan `scopeWhere()`, que devuelve el
  « WHERE …» completo. Concatenar `scopeClause()` sin un WHERE previo produce SQL inválido
  («syntax error near 'AND 1 = 0'») y el panel devuelve 500 a todo jefe de departamento.
- **`tools/migrar.php --estado` necesita la base encendida.** `Database::tableExists()` se
  traga la excepción de conexión y devuelve `false`, así que sin base la herramienta
  anunciaba «0 migraciones aplicadas» en lugar de avisar del problema. Desde octubre de 2026
  la herramienta comprueba la conexión antes de informar el estado.
- **MySQL/MariaDB de XAMPP no es un servicio: hay que arrancarlo a mano.**
  `C:\xampp\mysql\bin\mysqld.exe --defaults-file=C:\xampp\mysql\bin\my.ini`. Sin él, el
  servidor web responde 200 en el login pero todo lo que toque la base falla.
- **`fetch` no informa del avance de la subida.** Para una barra de progreso real hay que
  usar `XMLHttpRequest` y su evento `upload.progress` (con `withCredentials`, la cabecera
  `X-CSRF-TOKEN` y `X-Requested-With`). En la pantalla de importación se hace así
  (`public/assets/js/importaciones.js`); el resto del sistema sigue con `PV.api`.
- **El cliente `mysql.exe` de Windows mangla los acentos**: un `UPDATE` con texto en español
  deja «?» en la base. Para textos con acentos usar PHP (PDO con `charset=utf8mb4`) o el
  literal hexadecimal `CONVERT(0x… USING utf8mb4)`.
- **La zona de arrastre (`/importaciones`) no usa `input.files`** para el archivo arrastrado:
  el `File` se guarda en memoria y se manda en el `FormData`. El `<input type="file">` real
  queda oculto con `.sr-only` y solo sirve para el diálogo del sistema. La tarjeta del archivo
  (`.dropzone__file`) va **fuera** de la zona: un botón dentro de un `role="button"` anidado
  rompe la navegación por teclado.

## Comandos de verificación

```bash
C:\xampp\php\php.exe -l <archivo>                      # sintaxis
C:\xampp\php\php.exe vendor\bin\phpunit                # suite completa
C:\xampp\php\php.exe tools\probar-ruta.php /ruta       # sonda de rutas
C:\xampp\php\php.exe tools\verificar-rutas.php         # recorre todas las rutas y detecta 5xx
C:\xampp\php\php.exe tools\buscar-marcadores-repetidos.php
C:\xampp\php\php.exe tools\migrar.php --estado         # estado de migraciones
C:\xampp\php\php.exe tools\instalar-cli.php --help      # instalador por CLI
C:\xampp\php\php.exe tools\pdf-muestra.php             # genera PDF de muestra
C:\xampp\php\php.exe tools\datos-demo.php --truncar    # datos de demostración
C:\xampp\php\php.exe tools\cargar-presupuesto.php      # carga el catálogo oficial de partidas
node tools\extraer-presupuesto.js "<presupuesto.pdf>" # extrae el catálogo del PDF y valida los subtotales
C:\xampp\php\php.exe tools\empaquetar.php               # genera el ZIP distribuible
C:\xampp\php\php.exe tools\proveedor-ia-mock.php 8099   # proveedor de IA de prueba (local)
node tools\capturar-tutorial.js --salida docs\tutorial\assets\capturas   # capturas del tutorial
```

Base de pruebas: se recrea desde cero en cada ejecución de PHPUnit
(`tests/bootstrap.php` la borra, la crea y aplica migraciones y seeders). El bootstrap
**no** sobrescribe `config/app.php`: cambia la base en memoria.

## Documentos de referencia

- `docs/ARQUITECTURA.md` — esquema de datos, servicios, reglas presupuestales.
- `docs/CONTRATO-DESARROLLO.md` — convenciones obligatorias de código.
- `docs/SISTEMA-DISENO.md` — sistema de diseño: tokens, componentes y checklist para
  módulos nuevos.
- `docs/MANUAL-INSTALACION.md`, `docs/MANUAL-ADMINISTRACION.md`, `docs/DESPLIEGUE-CPANEL.md`.
- `docs/tutorial/` — tutorial visual y narrado del sistema (HTML con audio y PDF).
  Se abre con `index.html`; se regenera con `tools/capturar-tutorial.js`. No entra en el
  paquete distribuible: pesa demasiado y se reparte aparte.
- `docs/referencia/` — imágenes oficiales de la orden de compra, del vale de salida y del formato
  del requerimiento de compra; más `requerimiento-compra-ejemplo.pdf`, que es una muestra del
  documento que genera el sistema.

## Estado

Todos los módulos implementados: proveedores, departamentos, catálogos, partidas, órdenes,
vales, panel, reportes, asistente de IA, configuración, usuarios/roles, bitácora,
requerimientos de compra y documentación de órdenes. Suite de **258 pruebas / 1725 aserciones**
en verde (incluye una prueba de humo de todas las API con término de búsqueda, 37 del
asistente de IA —11 del lector tolerante de JSON—, 9 de identidad institucional, 7 de
apariencia, 17 de roles, 7 de entrega de vales, 10 del catálogo global de partidas, 13 del gasto
por departamento y el reporte de artículos comprados, 3 de las herramientas nuevas de la IA, 3
del panel con alcance por departamento, 27 de la importación de catálogos, 9 de la paginación de
la API, 16 del módulo de requerimientos de compra, 20 del de documentación de órdenes y 18 del
ajuste de entrega).

Catálogo global de partidas: una sola tabla con los tres niveles del *Presupuesto de Egresos*
(capítulo, concepto y partida genérica), repetido por ejercicio fiscal. El catálogo oficial
del ejercicio 2026 (8 capítulos, 39 conceptos, 121 partidas, $1,024,569,217.00) viaja en
`database/data/presupuesto-2026.json`, lo extrae `tools/extraer-presupuesto.js` del PDF oficial
y lo carga `BudgetCatalog::load()`. En la orden de compra se elige **departamento** (centro de
costo) **y partida** (bolsa presupuestal) por separado. Ver `docs/PARTIDAS-PRESUPUESTALES.md`.

Roles y permisos: el administrador crea sus propios roles con el nombre que elija, les asigna
permisos y los elimina. Los roles del sistema no se pueden eliminar, ni los que tengan personas
asignadas. Los permisos se muestran en español: `App\Support\PermissionLabels` es la única fuente
de verdad de las etiquetas (13 módulos y 13 acciones); el nombre técnico `modulo.accion` sigue
siendo lo que comprueba el sistema.

Vales de salida: el almacén puede registrar la entrega con **Marcar como entregada**
(`VoucherService::deliver()`, permiso `vouchers.deliver`). El vale pasa de «emitido» a
«entregado» y graba quién y cuándo; no altera el presupuesto y no es reversible.

Módulo de IA: un solo cliente HTTP compatible con la API de OpenAI cubre OpenAI, DeepSeek y
cualquier endpoint compatible (incluido Ollama en local). El modelo redacta; las cifras las
calcula el backend a partir de `ReportCatalog`. Ver `docs/ARQUITECTURA.md` §12.

Importación masiva de catálogos: una pantalla (`/importaciones`) para proveedores, departamentos
y partidas presupuestales. La carga se hace en una **zona de arrastre** con estilo propio (el
`<input type="file">` nativo queda oculto) y una **barra de progreso** con el avance real de la
subida. La importación es **atómica**: si un renglón tiene error no se aplica ninguno. Permisos
`suppliers.import`, `departments.import` y `budget.import`. Ver `docs/ARQUITECTURA.md` §13 y
`docs/MANUAL-ADMINISTRACION.md` §2.4.

Requerimientos de compra: módulo (`/requerimientos`) donde los departamentos capturan el formato
institucional de solicitud (producto, especificaciones, unidad, cantidad; anexo de muestra;
observaciones). Ciclo completo **borrador → enviado → autorizado → convertido**, con folio
`REQ-000001` reservado al enviar y con historial de cambios. Un requerimiento **no mueve
presupuesto**: al convertirlo se crea la orden de compra **en borrador** con los renglones copiados
y el folio del requerimiento como referencia; el presupuesto se compromete al emitir la orden. El
jefe de departamento solo ve y captura los requerimientos de su área. Permisos `requisitions.*`.
Ver `docs/ARQUITECTURA.md` §14 y `docs/MANUAL-ADMINISTRACION.md` §2.5.

Documentación de las órdenes: cada orden admite adjuntos (factura, contrato, acta de entrega,
cotización, garantía u otro) en `storage/documentos/`, fuera de la raíz web y **no** servidos
por la ruta pública `/media/{archivo}`: la descarga exige sesión y permiso. Para la factura se
registran folio, fecha y monto, lo que avisa si la orden ya tiene factura y concilia el
facturado contra el total de la orden. Adjuntar **no** mueve presupuesto. Permiso
`orders.documents` (asignable por rol). Ver `docs/ARQUITECTURA.md` §15 y
`docs/MANUAL-ADMINISTRACION.md` §2.6.

Tutorial visual: `docs/tutorial/index.html` con diez capítulos, capturas reales anotadas,
narración en audio y versión en PDF. Es el material de capacitación del personal; el manual
`docs/MANUAL-ADMINISTRACION.md` sigue siendo la referencia completa.
