# Contrato de desarrollo — Sistema Integral de Proveeduría Municipal

Este documento es **normativo**. Todo el código nuevo debe ajustarse a estas convenciones
para que los módulos sean intercambiables y mantenibles.

---

## 1. Estructura y propiedad de archivos

```
app/
  Controllers/        Un controlador por módulo
  Repositories/       Acceso a datos (SQL parametrizado)
  Services/           Reglas de negocio (ya existen los servicios núcleo)
  Validators/         Validadores de entrada
  Views/<módulo>/     Vistas .php
public/assets/js/     Un archivo .js por módulo
routes/modules/       Un archivo .php por módulo (devuelve un closure)
resources/document-templates/  Plantillas PDF (.php)
```

**Regla de oro:** cada módulo es dueño exclusivo de sus archivos. No se modifica
código de otro módulo ni del núcleo salvo que se avise explícitamente.

---

## 2. Núcleo ya implementado (no reimplementar)

| Clase | Responsabilidad |
|---|---|
| `App\Core\Router` | Enrutado con `{param}` y grupos de middleware |
| `App\Core\Request` / `Response` | Entrada/salida HTTP |
| `App\Core\Database` | PDO. `select`, `selectOne`, `insert`, `update`, `delete`, `transaction` |
| `App\Core\Auth` | `Auth::check()`, `Auth::can('modulo.accion')`, `Auth::user()`, `Auth::id()` |
| `App\Core\Csrf` | Token en sesión; header `X-CSRF-TOKEN` o campo `_token` |
| `App\Core\Audit` | `Audit::log($accion, $entidad, $id, ['description' => ...])` |
| `App\Core\Settings` | `Settings::get('clave')`, `Settings::set()`, `Settings::group('identity')` |
| `App\Core\Validator` | Validación declarativa (ver abajo) |
| `App\Core\Controller` | `view()`, `json()`, `error()`, `redirect()`, `validate()`, `money()` |
| `App\Support\Money` | Aritmética decimal exacta (ver §5) |
| `App\Support\Icon` | `Icon::render('truck', 'clase', 18)` |
| `App\Services\BudgetService` | Reglas presupuestales y bloqueos de fila |
| `App\Services\SequenceService` | Folios transaccionales (nunca `MAX()+1`) |
| `App\Services\OrderWorkflowService` | Emisión y cancelación de órdenes |
| `App\Services\VoucherService` | Vales de salida |
| `App\Services\DocumentRenderer` | Generación de PDF con Dompdf |

---

## 3. Controlador

```php
<?php

declare(strict_types=1);

namespace App\Controllers;          // OBLIGATORIO

use App\Core\Controller;
use App\Core\HttpException;
use App\Core\Request;              // OBLIGATORIO
use App\Core\Response;             // OBLIGATORIO
use App\Core\View;

final class ProveedoresController extends Controller
{
    public function index(Request $request): Response
    {
        return $this->view('proveedores/index', [
            'pageTitle' => 'Proveedores',
            'breadcrumbs' => [['label' => 'Proveedores']],
        ]);
    }

    public function store(Request $request): Response
    {
        $data = $this->validate($request->body, [
            'name' => 'required|string|max:200',
            'rfc' => 'nullable|rfc',
        ]);

        // ... lógica ...

        return $this->json(['id' => $id]);
    }
}
```

Reglas:
- **Todo controlador declara `namespace App\Controllers;` e importa `App\Core\Request`
  y `App\Core\Response`.** Sin el namespace el autoload carga el archivo dos veces
  (error fatal "Cannot declare class"); sin los imports la firma resuelve a
  `App\Controllers\Request` y la llamada falla con un TypeError.
- Toda acción de escritura devuelve JSON; las vistas se sirven con `view()`.
- Las excepciones `HttpException` se translated automáticamente a JSON
  (422 con `fields`) o a la página de error.
- **El backend siempre valida y recalcula.** Nunca confíes en el cálculo del navegador.

---

## 4. Rutas del módulo

`routes/modules/proveedores.php`:

```php
<?php

declare(strict_types=1);

use App\Controllers\ProveedoresController;
use App\Core\Router;

return function (Router $router): void {
    $router->get('/proveedores', [ProveedoresController::class, 'index'], ['auth', 'permission:suppliers.view']);
    $router->post('/api/proveedores', [ProveedoresController::class, 'store'], ['auth', 'permission:suppliers.create', 'csrf']);
    $router->get('/api/proveedores', [ProveedoresController::class, 'index'], ['auth', 'permission:suppliers.view']);
    // ...
};
```

Middlewares disponibles: `auth`, `guest`, `csrf`, `permission:<clave>`.

### El identificador de la ruta se lee como `id`

El enrutador inyecta los parámetros de ruta en el cuerpo de la petición
(`Request::withParams()`), así que un controlador **debe leer el identificador con la misma clave
que aparece en la ruta**:

```php
// Ruta: $router->post('/api/roles/{id}/permisos', ...)
// Controlador:
$roleId = $request->integer('id');
```

Si la ruta dice `{id}` y el controlador lee `role_id` (o al revés), el identificador llega como 0
y la operación falla con un 404 engañoso («El rol no existe») aunque el rol exista. Es la
convención del proyecto: el parámetro se llama `id` y el controlador lo lee con
`$request->integer('id')`.

### Permisos existentes

`dashboard.view`, `suppliers.*`, `departments.*`, `budget.*`, `orders.*`,
`vouchers.*`, `reports.*`, `ai.use`, `ai.history`, `users.*`, `roles.*`,
`settings.*`, `audit.view`, `catalogs.manage`.

Si un módulo necesita un permiso nuevo, se documenta en el mensaje final
para que el núcleo lo agregue (no se edita `Seeder.php` desde un módulo).

---

## 5. Dinero — reglas obligatorias

```php
use App\Support\Money;

Money::toAmount('1,234.5')        // '1234.50'
Money::add('100.00', '0.10')      // '100.10'
Money::subtract('100.00', '0.10') // '99.90'
Money::multiply('3', '19.99')     // '59.97'  (redondeo half-up)
Money::percentOf('1000.00', '16') // '160.00'
Money::compare('10.00', '9.99')   // 1
Money::format('1234.5')           // '$1,234.50'
Money::formatPlain('1234.5')      // '1,234.50'
Money::isPositive('0.01')         // true
```

- **Nunca** uses `float` para importes. Todo pasa por `Money`.
- El IVA se redondea **por línea**, no sobre la suma.
- En base de datos los importes son `DECIMAL(15,4)`; las cantidades `DECIMAL(14,4)`.

---

## 6. Formato de la API interna

Éxito:

```json
{ "ok": true, "data": { }, "meta": { } }
```

Listas paginadas:

```json
{
  "ok": true,
  "data": { "items": [ ] },
  "meta": { "total": 120, "page": 1, "per_page": 25, "pages": 5 }
}
```

Error:

```json
{ "ok": false, "error": { "code": "validation_error", "message": "…", "fields": { "name": "…" } } }
```

Parámetros de consulta admitidos por las listas:
`q` (búsqueda), `page`, `per_page`, `sort`, `dir` (`asc`/`desc`), y filtros propios
(`status`, `department_id`, `fiscal_year_id`, `date_from`, `date_to`, …).

La API **debe** escapar el valor de `sort` contra una lista blanca de columnas
antes de interpolarlo en el `ORDER BY`.

---

## 7. Frontend

### CSS disponible

Clases del sistema de diseño en `public/assets/css/app.css`:
`card`, `card__header`, `card__body`, `card__footer`, `stat-grid`, `stat-card`,
`btn` (+ `btn--primary|secondary|ghost|danger|success|accent|sm|lg|icon|block`),
`input`, `select`, `textarea`, `form-grid` (+ `--2|--3|--4`), `form-field`,
`form-label`, `form-error`, `form-hint`, `table` (+ `table--compact`),
`table__num`, `table__primary`, `table__secondary`, `badge` (+ `badge--success|warning|danger|info|neutral|accent`),
`alert` (+ `alert--success|warning|danger|info`), `modal`, `modal-backdrop`,
`modal__header|body|footer`, `toolbar`, `toolbar__search`, `pagination`,
`progress`, `progress__bar` (+ `--warning|--danger|--success`), `empty-state`,
`tabs`, `tab`, `timeline`, `search`, `spinner`, `toast-container`, `chip`.

### JS disponible (`window.PV`, en `public/assets/js/app.js`)

```js
PV.api(url, {method, body})          // fetch con CSRF y manejo de errores
PV.get(url, params) / PV.post / PV.put / PV.delete
PV.toast(mensaje, 'success'|'error'|'warning'|'info', titulo)
PV.confirm({title, message, confirmText, danger, requireReason})  // Promise
PV.openModal(id) / PV.closeModal(id)
PV.showErrors(form, fields) / PV.clearErrors(form)
PV.serialize(form)
PV.setBusy(boton, true, 'Guardando…')
PV.formatMoney(v) / PV.formatDate(v) / PV.formatDateTime(v) / PV.escapeHtml(v)
PV.DataTable({...})                 // tabla con búsqueda, orden y paginación
PV.SearchSelect({...})              // selector con búsqueda asíncrona
```

`PV.DataTable` se inicializa así:

```html
<div class="card">
    <div class="toolbar">
        <div class="toolbar__search">
            <input class="input" id="busqueda" type="search" placeholder="Buscar por nombre, folio o RFC…">
        </div>
        <div class="toolbar__filters">
            <select class="select" id="filtroEstado"><option value="">Todos los estados</option></select>
        </div>
    </div>
    <div id="tabla"></div>
</div>
```

```js
const tabla = PV.DataTable({
    mount: '#tabla',
    endpoint: '/api/proveedores',
    searchInput: '#busqueda',
    columns: [
        { key: 'supplier_number', label: 'No. proveedor', sortable: true, className: 'table__primary' },
        { key: 'name', label: 'Razón social', sortable: true },
        { key: 'total', label: 'Total', align: 'right', render: (row) => PV.formatMoney(row.total) },
    ],
});
```

Para filtrar, se llama `tabla.setFilter('status', valor)` desde un `change`.

### `PV.confirm` resuelve `true` o `null`

`PV.confirm()` devuelve una promesa que se resuelve con `true` cuando se confirma y con `null`
cuando se cancela (o se pulsa Escape o fuera del cuadro). **No recibe un objeto con `onConfirm`**:
la firma es `PV.confirm({title, message, confirmText, cancelText, danger, requireReason})` y el
resultado se espera con `.then()`:

```js
PV.confirm({
    title: 'Eliminar rol',
    message: 'Se eliminará el rol «' + name + '» y sus permisos.',
    confirmText: 'Eliminar rol',
    danger: true,
}).then(async function (confirmed) {
    if (!confirmed) {
        return;
    }
    // ... la acción de borrado ...
});
```

Si se necesita el motivo del borrado, `requireReason: true` hace que la promesa se resuelva con
`{ reason: '...' }` en lugar de `true`.

El JS de cada módulo se carga con `<script src="...">`: se añade la clave
`$scripts` al array de datos de la vista y el layout lo incluye al final.

---

## 8. Vistas

- Se renderizan con `View::render()` y reciben un array de datos.
- Escapar SIEMPRE la salida: `<?= View::e($valor) ?>`.
- El layout `layouts/app` ya aporta barra lateral, barra superior, migas de pan y avisos.
- Las vistas de módulo viven en `app/Views/<módulo>/`.
- Para pasar scripts adicionales: `$scripts = ['js/proveedores.js'];`

---

## 9. Reglas de negocio transversales

1. **Folio definitivo**: una orden emitida muestra siempre su folio, nunca «PENDIENTE».
2. **Presupuesto**: los borradores no descuentan; emitir sí; cancelar revierte.
3. **Integridad**: toda emisión/cancelación es transaccional (`Database::transaction`).
4. **Concurrencia**: dos emisiones simultáneas no pueden compartir folio ni sobregastar.
5. **Auditoría**: todo alta, modificación, emisión, cancelación y cambio de configuración
   se registra con `Audit::log()`.
6. **Sin datos simulados**: las pantallas leen de MySQL. Si no hay registros, se muestra
   el estado vacío, nunca datos falsos.
7. **Eliminación**: los registros con dependencias se desactivan, no se borran.
8. **Historial**: las operaciones irreversibles conservan su rastro (`audit_logs`,
   `budget_movements`, `order_events`).
9. **Inteligencia artificial**: el modelo redacta, el sistema calcula. El modelo no escribe
   SQL ni decide qué cifras se muestran; usa el catálogo de herramientas de `AiTools` y las
   consultas ya probadas de `ReportCatalog`. El recorte de datos se aplica en el servidor
   (`AiDataScope`), antes de la llamada externa. La clave de API se guarda como secreto y
   nunca llega al navegador (`Settings::allPublic()`).
10. **Identidad institucional**: el Kernel la comparte con `View::share('identity', ...)`.
    Una vista la lee de `$identity` (la variable compartida), **nunca** de
    `$GLOBALS['identity']`, que no existe: eso vacía el valor y hace que los cambios de
    color, nombre y logotipo no se apliquen.
11. **Archivos subidos**: viven en `storage/`, fuera de la raíz web. Para enlazarlos se usa
    `Media::url($ruta)`, que produce `/media/{archivo}` para lo que está en `storage/` y la
    ruta directa para lo que está en `public/`. Un `View::url('/storage/...')` da 404.

---

## 9 bis. Añadir una herramienta al asistente

1. Definir el reporte y su consulta en `app/Services/ReportCatalog.php` (`catalog()` y
   `build()`), o reutilizar uno existente.
2. Declarar la herramienta en `app/Services/AI/AiTools.php`: `map()` con la descripción para
   el modelo y `schemas()` con su esquema de parámetros.
3. Si la consulta necesita resolver un nombre (departamento, proveedor, ejercicio), ampliar
   `resolveFilters()`. El modelo manda nombres, no identificadores.
4. Revisar la política de alcance: si la herramienta expone filas de detalle, el nivel
   configurado decide qué sale del servidor.
5. Cubrirlo con una prueba en `tests/Unit/AiToolsTest.php` o `tests/Feature/AiReportTest.php`.

---

## 10. Verificación obligatoria antes de entregar

- `php -l` en todos los archivos PHP creados o modificados.
- Comprobar que las rutas responden (200 en HTML, `ok:true` en JSON).
- Probar el flujo completo en el navegador, no solo la vista.
- Reportar explícitamente qué se probó y qué no.
