# Arquitectura técnica

Documento de referencia para el mantenimiento y la ampliación del Sistema Integral de Proveeduría
Municipal: esquema de datos, servicios, responsabilidades y las reglas presupuestales.

---

## 1. Principios de diseño

1. **Sin dependencias pesadas.** PHP puro con PSR-4, sin framework MVC. Las únicas dependencias
   son `dompdf/dompdf` (PDF) y `phpunit/phpunit` (pruebas).
2. **Las reglas de negocio viven en servicios**, no en los controladores. Un controlador valida,
   orquesta y responde.
3. **El navegador nunca es la fuente de la verdad.** El backend recalcula siempre los importes.
4. **La concurrencia se resuelve en la base de datos**, con bloqueos de fila, no con bloqueos
   en PHP.
5. **Nada se borra si tiene historia.** Se desactivan los registros con dependencias.
6. **Todo cambio relevante se audita.**

---

## 2. Estructura del código

```
app/
  Core/          Infraestructura: Router, Request, Response, Database, Config, Session,
                 Csrf, Auth, Audit, Settings, Validator, View, Migrator, Seeder, Kernel
  Support/       Utilidades puras: Money, Icon, FileUpload
  Services/      Reglas de negocio: BudgetService, SequenceService, OrderWorkflowService,
                 VoucherService, DocumentRenderer, Installer
  Controllers/   Un controlador por módulo
  Repositories/  Acceso a datos por entidad
  Views/         Plantillas PHP (layouts, partials y vistas de módulo)
```

### El núcleo (app/Core)

| Clase | Responsabilidad |
|---|---|
| `Kernel` | Ciclo de la petición: middlewares, despacho, manejo de errores y registro |
| `Router` | Rutas con `{param}`, grupos y middlewares (`auth`, `guest`, `csrf`, `permission:x`) |
| `Request` / `Response` | Entrada y salida HTTP; `Request::withParams()` incorpora los parámetros de ruta |
| `Database` | PDO con consultas parametrizadas, transacciones y savepoints anidados |
| `Config` | Lectura y escritura de `config/app.php`, que vive **fuera** de `public/` |
| `Session` | Sesión con `HttpOnly`, `SameSite=Lax` y `Secure` automático bajo HTTPS, con caducidad por inactividad |
| `Csrf` | Token por sesión, validado por campo `_token` o cabecera `X-CSRF-TOKEN` |
| `Auth` | Autenticación, control de intentos fallidos, permisos y alcance por departamento |
| `Audit` | Bitácora de solo lectura, con valores anteriores y posteriores |
| `Settings` | Configuración editable en base de datos, con caché en memoria |
| `Validator` | Validación declarativa de la entrada |
| `View` | Motor de plantillas con layouts, escape automático y variables compartidas |
| `Migrator` | Migraciones versionadas con registro en la tabla `migrations` |
| `Seeder` | Roles, permisos, catálogos y configuración inicial |

---

## 3. Esquema de datos

Prefijo configurable (por defecto `pv_`), motor InnoDB, `utf8mb4_unicode_ci`.

### 3.1 Identidad y seguridad

| Tabla | Contenido | Notas |
|---|---|---|
| `users` | Cuentas de acceso | `username` y `email` únicos, `password_hash`, `role_id` |
| `roles` | Roles del sistema y roles propios | `slug` único, `is_system` (1 = del sistema, 0 = propio), `description` |
| `permissions` | Catálogo de permisos | `name` con formato `modulo.accion`, único |
| `role_permissions` | Asignación rol → permiso | Tabla puente con clave primaria compuesta; `ON DELETE CASCADE` |
| `user_departments` | Alcance del jefe de departamento | Par (usuario, departamento) |
| `login_attempts` | Intentos de acceso | Base del bloqueo por intentos fallidos |
| `audit_logs` | Bitácora | Usuario, acción, entidad, valores antes/después, IP, agente |

**Roles propios.** El administrador puede crear roles con el nombre que necesite
(`is_system = 0`, identificador único derivado del nombre, sin acentos) y eliminarlos. Un rol del
sistema no se puede eliminar, y tampoco uno que tenga personas asignadas: borrarlo dejaría a esos
usuarios sin acceso (`users.role_id` está en `ON DELETE SET NULL`). El nombre técnico del permiso
(`modulo.accion`) sigue siendo lo que comprueba el sistema; lo que ve el administrador es la
traducción al español de `App\Support\PermissionLabels`, que es la única fuente de verdad de esas
etiquetas.

### 3.2 Catálogos

| Tabla | Contenido | Notas |
|---|---|---|
| `departments` | Departamentos | `name` único; `is_active` en lugar de borrado |
| `fiscal_years` | Ejercicios fiscales | `year` único |
| `units` | Unidades de medida | `abbreviation` única |
| `suppliers` | Proveedores | `supplier_number` único, `deleted_at` para borrado lógico |
| `supplier_documents` | Documentos de respaldo del proveedor | Preparado para la vigencia del 32-D |

### 3.3 Control presupuestal

El catálogo de partidas presupuestales es **global**: no pertenece a ningún departamento y se
repite por ejercicio fiscal. Sigue el *Presupuesto de Egresos* oficial (capítulo / concepto /
partida genérica). Ver `docs/PARTIDAS-PRESUPUESTALES.md`.

| Tabla | Contenido | Notas |
|---|---|---|
| `budget_items` | Catálogo de partidas presupuestales | Único por (ejercicio fiscal, número de partida). Sin `department_id` |
| `budget_item_departments` | Quién consumió de cada partida | Único por (partida, departamento). Mantenido con la transacción de la orden |
| `budget_movements` | Historial de movimientos | `ampliacion`, `reduccion`, `compromiso`, `liberacion` |
| `budget_authorizations` | Autorizaciones extraordinarias | Con justificación y vigencia |

Columnas de `budget_items` e importes:

```
level               VARCHAR(16)        capitulo | concepto | partida
item_number         VARCHAR(40)        1000 · 1100 · 111
chapter_code        VARCHAR(10)        capítulo al que pertenece
concept_code        VARCHAR(10)        concepto al que pertenece (solo nivel partida)
authorized_budget   DECIMAL(15,4)      Presupuesto autorizado
amplified_budget    DECIMAL(15,4)      Suma de ampliaciones
reduced_budget      DECIMAL(15,4)      Suma de reducciones
spent_amount        DECIMAL(15,4)      Presupuesto comprometido por órdenes emitidas
```

Solo las filas de nivel `partida` (código de 3 dígitos) admiten movimientos y se pueden
asignar a una orden. Los capítulos y los conceptos son agrupadores: sus saldos son la **suma
de las partidas hijas** (`BudgetService::totals()`).

El catálogo oficial del ejercicio 2026 (8 capítulos, 39 conceptos, 121 partidas genéricas,
total $1,024,569,217.00) viaja en `database/data/presupuesto-2026.json` y lo carga
`BudgetCatalog::load()`; `tools/extraer-presupuesto.js` lo extrae del PDF y valida que los
subtotales cuadren.

### 3.4 Documentos

| Tabla | Contenido | Notas |
|---|---|---|
| `purchase_orders` | Encabezado de la orden | `folio` NULL en borrador; único por (ejercicio, folio) |
| `purchase_order_items` | Conceptos | `quantity` DECIMAL(14,4), `unit_price` DECIMAL(15,4) |
| `order_events` | Historial de la orden | Alta, emisión, cancelación |
| `warehouse_vouchers` | Vales de salida | `folio` único, `purchase_order_id` único; `delivered_at` y `delivered_by` registran la entrega |
| `warehouse_voucher_items` | Conceptos del vale | Copia de los conceptos de la orden |
| `document_sequences` | Contadores de folio | Único por (tipo de documento, ámbito) |

### 3.5 Infraestructura

| Tabla | Contenido |
|---|---|
| `settings` | Configuración editable (`key` única, `value`, `type`, `group_name`, `is_secret`). El grupo `identity` guarda nombre, colores, logotipo, **color de la barra lateral**, **tipografía** y **tamaño del texto**; el grupo `ai`, la clave del proveedor (marcada como secreto) |
| `migrations` | Migraciones ejecutadas |

### 3.6 Relaciones

```
budget_items 1──n purchase_orders n──1 suppliers
      │                  │
      │                  1──1 warehouse_vouchers
      │                  │
      │                  └──n purchase_order_items
      │
      ├──n budget_movements
      └──n budget_item_departments n──1 departments

departments 1──n purchase_orders      (centro de costo)
departments 1──n warehouse_vouchers
departments 1──n budget_item_departments

fiscal_years 1──n budget_items
fiscal_years 1──n budget_item_departments
fiscal_years 1──n purchase_orders
roles 1──n users ; roles n──n permissions
users n──n departments
```

---

## 4. Reglas presupuestales

### 4.1 Fórmulas

```
vigente      = authorized_budget + amplified_budget - reduced_budget
comprometido = spent_amount            (solo órdenes EMITIDAS no canceladas)
disponible   = vigente - comprometido
```

`BudgetService` centraliza estos cálculos: `vigente()`, `comprometido()`, `disponible()` y
`usedPercent()`. Ninguna vista recalcula a mano.

### 4.2 Los diez invariantes

1. Un borrador **no** descuenta presupuesto.
2. Emitir **sí** descuenta, dentro de la misma transacción que la emisión.
3. Cancelar **devuelve** el presupuesto mediante un movimiento `liberacion`.
4. Cambiar el presupuesto autorizado **no** sobrescribe: genera `ampliacion` o `reduccion`.
5. Una reducción **no** puede dejar el presupuesto vigente en negativo.
6. El historial de movimientos **no** se modifica ni se borra.
7. La partida se distingue por **ejercicio fiscal y número de partida**: el catálogo es global y
   no pertenece a ningún departamento. El departamento de la orden es su centro de costo.
8. Solo las **partidas genéricas** admiten movimientos y se pueden asignar a una orden: un
   capítulo o un concepto es un agrupador y sus saldos son la suma de sus partidas hijas.
9. El mismo movimiento **no** se aplica dos veces: la emisión y la cancelación son idempotentes
   (una orden emitida no se puede emitir otra vez; una cancelada no se puede volver a cancelar).
10. Dos operaciones simultáneas **no** pueden gastar el mismo saldo: la partida se bloquea con
    `SELECT … FOR UPDATE` antes de calcular y de escribir.

### 4.3 Qué significa «Gastado»

La interfaz muestra la columna como **Gastado**, y corresponde al **presupuesto comprometido
mediante órdenes de compra emitidas**. No representa pagos realizados: el sistema no incluye
un módulo de pagos ni de Accounts Payable. La distinción queda documentada en la propia interfaz
y en el manual de administración.

### 4.4 Sobregasto

Por defecto, una orden no puede exceder el disponible. La excepción exige una fila en
`budget_authorizations` con justificación y vigencia, y queda registrada en la bitácora con el
detalle del importe y la partida.

---

## 5. Ciclo de vida de la orden

```
                    ┌──────────────┐
   crear ─────────►│   BORRADOR   │  (no compromete presupuesto)
                    └──────┬───────┘
                           │ emitir (transaccional)
                           │  1. bloquear partida
                           │  2. verificar disponible
                           │  3. reservar folio
                           │  4. movimiento «compromiso»
                           │  5. crear vale + su folio
                           ▼
                    ┌──────────────┐
                    │    EMITIDA   │  ── cancelar (motivo) ──► ┌──────────────┐
                    └──────────────┘                          │  CANCELADA   │
                                                             └──────────────┘
                                                               (libera presupuesto)
```

`OrderWorkflowService::issue()` y `::cancel()` son las únicas puertas de entrada a esos estados.

### 5 bis. Ciclo de vida del vale de salida

El vale nace con la orden emitida y tiene tres estados:

```
                    ┌──────────────┐
                    │   EMITIDO    │  (nace con la orden)
                    └──────┬───────┘
                           │ VoucherService::deliver()
                           ▼
                    ┌──────────────┐
                    │  ENTREGADO   │  (el almacén entregó la mercancía)
                    └──────────────┘
        (la cancelación de la orden lo pasa a CANCELADO)
```

`VoucherService::deliver()` es la única puerta de entrada al estado «entregado»: bloquea la fila
(`SELECT … FOR UPDATE`), admite solo el paso desde «emitido» y graba `delivered_at` y
`delivered_by`. **No altera el presupuesto**: el importe se compromete al emitir la orden, no al
entregar. La acción no es reversible.

---

## 6. Folios

`SequenceService` mantiene el contador en `document_sequences` y lo incrementa con
`SELECT … FOR UPDATE` dentro de la transacción. **Nunca** se usa `MAX(folio) + 1`.

- Las órdenes y los vales tienen **secuencias independientes** (`purchase_order` y
  `warehouse_voucher`).
- El ámbito (`scope_key`) es el ejercicio fiscal, de modo que la numeración puede reiniciarse
  por año sin reutilizar números ya emitidos.
- El prefijo y la longitud se guardan en la propia fila de la secuencia: cambiar la configuración
  no altera el formato histórico.

---

## 7. Aritmética monetaria

`App\Support\Money` trabaja con **enteros escalados a 10⁻⁴**. No hay `float` en el camino
de cálculo.

```
Money::toAmount('1,234.56')     → '1234.56'
Money::add('100.00','0.10')      → '100.10'
Money::multiply('3','19.99')     → '59.97'   (redondeo half-up exacto)
Money::percentOf('1000','16')    → '160.00'
Money::format('1234.5')          → '$1,234.50'
```

- El **IVA se redondea por línea**: `iva = redondeo(importe_de_la_línea × tasa)`. La suma de
  los importes por línea puede diferir en centavos del cálculo sobre el subtotal; la regla
  adoptada es la suma por línea.
- El redondeo es **half-up** (0.125 → 0.13), no half-even.
- En la base de datos los importes son `DECIMAL(15,4)` y las cantidades `DECIMAL(14,4)`,
  para admitir precios unitarios con cuatro decimales sin pérdida.

---

## 8. Documentos PDF

`DocumentRenderer` renderiza plantillas PHP de `resources/document-templates/` con Dompdf.

- La plantilla recibe `$order`/`$voucher`, `$items`, `$identity`, `$settings`, `$logoPath` y `$css`.
- Dompdf controla los márgenes con la regla CSS `@page`; el renderizador la inyecta al final
  del documento para que prevalezca sobre la definición base.
- La orientación y el tamaño de papel son configurables por tipo de documento.
- Las tablas usan `<thead>` para que el encabezado se repita en cada página, y
  `page-break-inside: avoid` en las filas.
- El logotipo se pasa como **ruta absoluta del sistema de archivos**: Dompdf no descarga
  recursos remotos (`isRemoteEnabled = false`).

Plantillas incluidas:

| Plantilla | Documento |
|---|---|
| `purchase-order.php` | Orden de compra y servicios |
| `warehouse-voucher.php` | Vale de salida de almacén |
| `report.php` | Reportes administrativos |

---

## 9. API interna

Formato uniforme:

```json
{ "ok": true, "data": { }, "meta": { } }
{ "ok": false, "error": { "code": "...", "message": "...", "fields": { } } }
```

Las listas usan `data.items` y `meta` con `{total, page, per_page, pages}`. Los parámetros
aceptados son `q`, `page`, `per_page`, `sort`, `dir` y los filtros propios del módulo. El valor
de `sort` se valida siempre contra una lista blanca de columnas antes de construir el `ORDER BY`.

Las escrituras exigen el middleware `csrf` y el permiso del módulo.

---

## 10. Seguridad

| Riesgo | Mitigación |
|---|---|
| Inyección SQL | Consultas preparadas de PDO; el `ORDER BY` se valida contra lista blanca |
| XSS | Escape con `View::e()` en todas las salidas |
| CSRF | Token por sesión en campo `_token` y cabecera `X-CSRF-TOKEN` |
| Robo de sesión | Cookie `HttpOnly`, `SameSite=Lax`, `Secure` bajo HTTPS, regeneración al iniciar sesión |
| Fuerza bruta | Registro de intentos y bloqueo temporal tras cinco fallos |
| Escalada de privilegios | Permisos por rol verificados en el middleware y en cada escritura |
| Fuga de credenciales | `config/app.php` fuera de `public/`; `.htaccess` y reglas de Nginx bloquean el acceso directo |
| Subida de archivos maliciosos | Validación del tipo real, límite de tamaño, nombre aleatorio y revisión del SVG contra scripts |
| Sesiones prolongadas | Caducidad por inactividad (60 minutos por defecto) |
| Exposición de información | `display_errors` desactivado en producción; errores al registro del servidor |

---

## 11. Puntos de extensión

| Necesidad | Dónde tocar |
|---|---|
| Un módulo nuevo | `app/Controllers/`, `app/Repositories/`, `app/Views/`, `public/assets/js/`, `routes/modules/` |
| Un permiso nuevo | `app/Core/Seeder.php` (método `permissions()`) |
| Un campo nuevo | Nueva migración en `database/migrations/` + modelo y vistas del módulo |
| Un nuevo tipo de documento | Nueva fila en `document_sequences` + servicio que use `SequenceService` |
| Un nuevo documento imprimible | Nueva plantilla en `resources/document-templates/` |
| Una regla de presupuesto | `app/Services/BudgetService.php` (y su prueba en `tests/Feature/`) |
| Un nuevo color o identidad | Configuración en base de datos (sin tocar código) |
| Una herramienta nueva para el asistente | `app/Services/AI/AiTools.php` (catálogo) + `app/Services/ReportCatalog.php` (consulta) |

Las convenciones obligatorias están en `docs/CONTRATO-DESARROLLO.md`.

---

## 12. Asistente de inteligencia artificial

Módulo opcional, en `app/Services/AI/`, con su interfaz en `/ia` y sus rutas en
`routes/modules/ia.php`.

### Principio rector

**El modelo redacta; el sistema calcula.** El modelo nunca escribe SQL y nunca decide qué
cifras se muestran. El flujo es:

1. El usuario escribe una pregunta de negocio.
2. El modelo recibe el catálogo de herramientas (`AiTools::schemas()`) y pide los datos que
   necesita. Se aceptan las dos formas en que un modelo pide una herramienta: la nativa
   (`tool_calls`) y la que escribe la petición como JSON en el contenido.
3. El sistema ejecuta la consulta ya probada del módulo de Reportes
   (`ReportCatalog::run()`) y devuelve las filas al modelo.
4. El modelo redacta la narrativa en JSON. Se lee con `AiJson`, un lector tolerante: la
   respuesta de un modelo casi nunca es JSON válido (comas finales, claves sin comillas,
   saltos de línea dentro de las cadenas) y, si se agotan los tokens, llega cortada.
5. El sistema conserva la narrativa y **compone la tabla con los datos que consultó**; el modelo
   solo elige *qué* herramienta destacar, y solo si esa herramienta se consultó de verdad.
6. El reporte se guarda en `pv_ai_reports` y se audita.

### Componentes

| Clase | Responsabilidad |
|---|---|
| `AiProviders` | Catálogo de proveedores (OpenAI, DeepSeek, OpenRouter, Ollama, compatible) y sus modelos vigentes |
| `AiSettings` | Lectura validada de la configuración; detección de problemas (sin clave, sin modelo) |
| `AiClient` | Cliente HTTP (cURL) de `/chat/completions`, compatible con la API de OpenAI |
| `AiChatGateway` | Interfaz del cliente: permite sustituirlo por un doble en las pruebas |
| `AiJson` | Lector tolerante de JSON: entiende la respuesta casi válida de un modelo y recupera la parte truncada |
| `AiTools` | Catálogo curado de herramientas y resolución de filtros por nombre |
| `AiDataScope` | Política de alcance: qué datos pueden salir del servidor |
| `AiReportService` | Orquestación, lectura de la respuesta, persistencia y auditoría |
| `ReportCatalog` | Definiciones y consultas de los reportes; las consume Reportes **y** el asistente |

### Garantías

- **Solo lectura**: el asistente no escribe en la base de datos. `pv_ai_reports` es el único
  registro que escribe, y lo hace el backend.
- **Sin SQL del modelo**: el catálogo de herramientas es fijo y su SQL ya está probado.
- **Recorte en el servidor**: `AiDataScope::apply()` filtra las filas *antes* de la llamada
  HTTP, según el nivel configurado.
- **Clave fuera del navegador**: se guarda marcada como secreto; `Settings::allPublic()` —la
  que usan las vistas— la excluye.
- **Auditoría**: cada generación queda en la bitácora con proveedor, modelo, herramientas y
  tokens.

### Tabla `pv_ai_reports`

| Columna | Contenido |
|---|---|
| `question` | Pregunta literal del usuario |
| `title`, `summary` | Encabezado y resumen del informe |
| `body` | Informe completo en JSON (secciones, hallazgos, recomendaciones, tabla, fuentes) |
| `provider`, `model` | Proveedor y modelo utilizados |
| `tools_used` | Herramientas consultadas |
| `scope` | Nivel de alcance aplicado |
| `prompt_tokens`, `completion_tokens`, `duration_ms` | Consumo de la llamada |

---

## 13. Importación masiva de catálogos

Carga de proveedores, departamentos y partidas presupuestales desde un archivo
(CSV, TXT o XLSX). Es **un solo módulo** (`/importaciones`) con tres catálogos:
desde el botón «Importar» de cada listado se llega a la misma pantalla con la
pestaña del catálogo activa.

### 13.1 Piezas

| Pieza | Responsabilidad |
|---|---|
| `App\Support\CsvReader` | Analiza CSV/TXT: BOM, UTF-8 o Windows-1252, separador `,` `;` o tabulador, campos entrecomillados con salto de línea, encabezados normalizados |
| `App\Support\SpreadsheetReader` | Abre XLSX con `ZipArchive` + SimpleXML (sin dependencias) |
| `App\Services\Import\ImportCatalog` | Contrato de un catálogo: columnas, aliases, clave natural, reglas y permisos |
| `ProveedorImporter`, `DepartamentoImporter`, `PartidaImporter` | Reglas de cada catálogo |
| `App\Services\Import\ImportService` | `catalogs()`, `readFile()`, `analyze()`, `apply()`, `template()` |
| `App\Services\Import\ImportBatch` | Lote en disco bajo `storage/imports/` (fuera de `public/`), con token y purga a las 24 h |
| `App\Controllers\ImportController` | Solo HTTP: `index`, `plantilla`, `catalogos`, `analizar`, `aplicar`, `descartar` |

El controlador **no aplica reglas de negocio ni audita**: el motor
(`ImportService`) es el que valida, escribe y deja la traza en la bitácora.

### 13.2 Flujo

1. `POST /api/importaciones/analizar` — lee el archivo y devuelve, renglón por
   renglón, la acción prevista (`crear`, `actualizar`, `error`) con sus avisos.
   **No escribe nada.** El contenido queda guardado en un lote con token.
2. `POST /api/importaciones/aplicar` — recibe el token, revalida **dentro de
   una transacción** y escribe. Si el lote ya no existe, responde 404 y el
   usuario vuelve a analizar el archivo.
3. `DELETE /api/importaciones/lote/{token}` — descarta la revisión pendiente.

### 13.3 Garantías

- **Atomicidad**: si un renglón tiene error, no se aplica ninguno. Se
  corrige el archivo y se vuelve a subir.
- **Previsualización sin escritura**: analizar no toca la base.
- **Revalidación en el momento de aplicar**: el lote en disco no sustituye a
  las reglas; se vuelven a comprobar al escribir.
- **Claves naturales**: proveedor por RFC (con respaldo en el número de
  proveedor), departamento por nombre, partida por (ejercicio fiscal, número
  de partida). Un proveedor sin RFC ni número recibe uno de la secuencia
  `IMP-000001`.
- **El presupuesto autorizado solo se escribe al crear** la partida. Si la
  partida ya existe y el importe difiere, la fila es **error** y remite a la
  ficha: ampliar o reducir se hace en la aplicación, nunca por archivo.
  Los capítulos y conceptos van con presupuesto 0.
- **Al actualizar, las celdas vacías no borran** datos existentes.
- **Límites**: 5 MB por archivo, 5 000 renglones por lectura.

### 13.4 Permisos

`suppliers.import`, `departments.import` y `budget.import`, uno por catálogo.
Vienen de `App\Support\PermissionLabels` con la etiqueta «Importar».

---

## 14. Requerimientos de compra

Lo que capturan los departamentos para pedir productos o servicios. Es una
**solicitud con folio propio**, no una orden: no tiene proveedor, ni partida
presupuestal, ni precios, y **no mueve presupuesto**. Reproduce el formato
institucional de referencia
(`docs/referencia/requerimiento-compra-referencia.png`).

### 14.1 Piezas

| Pieza | Responsabilidad |
|---|---|
| `App\Services\RequisitionService` | Ciclo de vida, validación, folio y conversión en orden de compra |
| `App\Repositories\RequisitionRepository` | Listado paginado con filtros y alcance por departamento |
| `App\Controllers\RequisicionesController` | Solo HTTP: `index`, `create`, `edit`, `show`, `apiIndex`, `store`, `update`, `submit`, `authorize`, `convert`, `cancel`, `pdf` |
| `resources/document-templates/purchase-requisition.php` | Documento PDF con el formato institucional |

Tablas: `pv_requisitions` (cabecera), `pv_requisition_items` (renglones) y
`pv_requisition_events` (historial de cambios).

### 14.2 Ciclo de vida

```
borrador ──enviar──> enviado ──autorizar──> autorizado ──convertir──> convertido
    │                    │                      │                        │
    └────────────────────┴──────────────────────┴──── cancelar ─────────┘
                                                              (en cualquier momento
                                                               antes de convertir)
```

- El **folio se reserva al ENVIAR**, no al crear: mientras el requerimiento es
  un borrador interno del departamento no consume numeración institucional.
  Secuencia `purchase_requisition` con prefijo `REQ-` (`documents.requisition_prefix`).
- Cada salto deja un **evento** con su usuario y su fecha, y una traza en la
  bitácora (`requisitions.create`, `.update`, `.submit`, `.authorize`,
  `.convert`, `.cancel`).
- Un requerimiento **cancelado no se borra**: queda con su motivo.

### 14.3 Conversión en orden de compra

Un requerimiento autorizado se convierte en una orden de compra **en
borrador**: los renglones se copian (producto, especificaciones, unidad y
cantidad), la fecha es la del requerimiento, el `requisition_number` de la
orden guarda el folio del requerimiento y el proveedor, la partida
presupuestal y los precios los elige el operador de compras.

La partida debe ser una **partida genérica**: un capítulo o un concepto se
rechazan con 422 (regla 13 del contrato).

La conversión **no compromete presupuesto ni genera vale**: eso ocurre cuando
la orden se emite, como cualquier otra orden (regla 5 del contrato).

### 14.4 Alcance por departamento

El jefe de departamento captura y ve **solo los requerimientos de su área**
(`Auth::scopeDepartments()`); no puede capturar para otra área (422). Proveeduría,
el operador de compras y el auditor trabajan con alcance completo.

### 14.5 Permisos

`requisitions.view`, `.create`, `.update`, `.submit`, `.authorize`,
`.convert`, `.cancel`, `.print` y `.export`. Las etiquetas en español están en
`App\Support\PermissionLabels`.

---

## 15. Documentación de las órdenes de compra

El respaldo documental de cada compra: facturas, contratos, actas de entrega,
cotizaciones, garantías y demás. Vive **dentro de la orden** para que todo el
respaldo de una compra esté en un solo lugar y no disperso en carpetas.

### 15.1 Piezas

| Pieza | Responsabilidad |
|---|---|
| `App\Services\OrderDocumentService` | Tipos, alta, baja, avisos de factura duplicada y conciliación de importes |
| `App\Controllers\OrdenDocumentosController` | Solo HTTP: `store`, `fragment`, `destroy`, `download` |

Tabla: `pv_order_documents` (`order_id`, `document_type`, `original_name`,
`stored_path`, `mime_type`, `file_size`, `document_number`, `document_date`,
`amount`, `notes`, `uploaded_by`). La baja de la orden arrastra la de sus
documentos (FK en cascada).

### 15.2 Tipos y datos de la factura

Los tipos son un catálogo fijo del servicio: `factura`, `contrato`,
`acta_entrega`, `cotizacion`, `garantia` y `otro`. Solo la **factura** admite
folio, fecha y monto; en los demás tipos el monto se descarta.

Para la factura se registran folio, fecha y monto, lo que habilita dos
controles:

1. **Aviso de duplicado**: al registrar una segunda factura en la misma orden
   se advierte cuántos folios había ya y cuáles son. No bloquea: una orden
   puede recibir varias facturas.
2. **Conciliación**: la suma de los montos facturados se compara contra el
   total de la orden y la diferencia se muestra en la misma pantalla.

### 15.3 Los archivos no se sirven por la ruta pública

Los documentos se guardan en `storage/documentos/`, **fuera de la raíz web**:

- No son una URL estática (darian 404) ni los expone la ruta pública
  `/media/{archivo}`, que solo sirve `storage/uploads/` (logotipos).
- La descarga pasa por `GET /api/ordenes/documentos/{documentId}/descargar`,
  con sesión y permiso `orders.view`, y devuelve el archivo con su **nombre
  original**.

Formatos admitidos: PDF, JPG y PNG, hasta 10 MB. El tipo real se comprueba con
`finfo` (no se confía en la extensión) y el nombre se genera aleatorio: el
nombre que escribió el usuario solo se guarda en la base de datos.

### 15.4 Adjuntar no es mover presupuesto

Adjuntar o eliminar un documento **no altera los importes de la orden ni
compromete presupuesto**: la orden se mide por sus conceptos, no por sus
adjuntos. La bitácora registra cada alta y cada baja
(`order.document.upload`, `order.document.delete`).

### 15.5 Permisos

`orders.view` permite consultar y descargar; `orders.documents` permite
adjuntar y eliminar. El permiso es independiente del rol: el administrador lo
asigna desde **Usuarios y roles** al perfil que lo necesite (por ejemplo, al
operador de compras).

---

## 16. Ajuste de entrega de la orden de compra

Una orden emitida no se toca... salvo por el **ajuste de entrega**: el proveedor
entregó menos cantidad de lo solicitado, o no surtió uno de los productos. El
renglón **no se elimina**: se cancela (queda como evidencia con su motivo) o se
le reduce la cantidad, y la orden se recalcula para que la factura sea la que
realmente se entrega.

### 16.1 El renglón tiene estado

`pv_purchase_order_items` gana cinco columnas:

| Columna | Para qué sirve |
|---|---|
| `status` | `activo` o `cancelado`. Los cancelados **no entran en los totales** ni en el compromiso presupuestal |
| `original_quantity` | La cantidad que se pidió: permite ver después lo que sí se entregó |
| `cancel_reason` | Motivo de la cancelación del renglón |
| `cancelled_at` / `cancelled_by` | Cuándo y quién canceló el concepto |

Un renglón cancelado **se conserva**: no se borra nunca. Aparece tachado en la
orden, con su motivo, y en el historial.

### 16.2 Sin precio unitario no se emite

Una orden con conceptos sin precio unitario **no se puede emitir**: el
compromiso presupuestal y la factura se calculan sobre ese precio. El bloqueo
está en el servidor (`OrderWorkflowService::issue()` responde 422 nombrando los
conceptos que lo necesitan) y la interfaz lo anticipa en el modal de resumen,
con el botón de confirmación deshabilitado.

El borrador **sí** se puede guardar sin precio: es el estado natural de una
orden derivada de un requerimiento, donde los precios los elige el operador de
compras.

### 16.3 El ajuste devuelve el presupuesto

Aplicar el ajuste a una orden emitida hace, **en una sola transacción**:

1. Recalcula los importes de los renglones afectados y los totales de la orden
   (el backend siempre recalcula; el cálculo del navegador es informativo).
2. **Devuelve a la partida lo que ya no se compra**: registra un movimiento de
   tipo `liberacion` por la diferencia entre el total anterior y el nuevo. El
   producto cancelado **no queda descontado del presupuesto**.
3. Corrige el desglose por departamento (`BudgetDepartmentService::adjust()`):
   ajusta el importe **sin** tocar el contador de órdenes, porque la orden
   sigue existiendo.
4. Actualiza el vale de salida **solo si no ha sido entregado** (ver 16.4).
5. Registra el evento en el historial y la entrada `orders.adjust` en la
   bitácora.

El ajuste **nunca aumenta** la cantidad: subirla exigiría comprometer dinero
adicional y no es un ajuste de entrega, sino una orden nueva. Tampoco se puede
cancelar **todos** los conceptos: para eso está la cancelación de la orden.

### 16.4 El vale sigue al ajuste mientras no se entregue

El vale de salida se genera al emitir la orden. Si el ajuste se aplica mientras
el vale sigue en estado `emitido`, sus conceptos se regeneran con las
cantidades nuevas: el vale documenta lo que sale del almacén y el almacén
todavía no ha entregado. **Un vale ya entregado no se modifica**: es el
registro físico de la salida y la entrega es un hecho.

### 16.5 Piezas y permisos

| Pieza | Responsabilidad |
|---|---|
| `App\Services\OrderAdjustmentService` | Aplica el ajuste: valida, recalcula, libera presupuesto y sincroniza el vale |
| `App\Services\OrderWorkflowService` | Bloquea la emisión sin precio unitario y excluye los renglones cancelados de los totales |
| `App\Repositories\OrdenRepository` | Guarda los conceptos conservando su estado, su motivo y la cantidad original |

API: `POST /api/ordenes/{id}/ajuste` con `{reason, quantities[], cancellations[]}`.

Permiso: **`orders.adjust`** («Ajustar entregas»), independiente del de editar
la orden y asignable por rol desde **Usuarios y roles**. Los conceptos
cancelados tampoco cuentan como artículos comprados en el reporte de artículos
comprados, y no se imprimen en el PDF de la orden: el documento oficial muestra
lo que se compra.
