# Catálogo global de partidas presupuestales

Especificación del cambio que sustituye las partidas por departamento por un
**catálogo único de partidas presupuestales** compartido por toda la
administración municipal, cargado desde el *Presupuesto de Egresos* oficial.

> Documento normativo. Si el código y este documento discrepan, se corrige el
> código. Toda trampa nueva que aparezca se documenta aquí y en `AGENTS.md`.

---

## 1. Decisiones de negocio

1. **Una sola tabla.** `pv_budget_items` contiene el catálogo completo
   (capítulos, conceptos y partidas genéricas). **No tiene `department_id`**:
   una partida no pertenece a un departamento, es de la administración.
2. **Por ejercicio fiscal.** El catálogo se repite por año: la partida `211` de
   2026 y la de 2027 son filas distintas con su propio presupuesto. La
   unicidad es `(fiscal_year_id, item_number)`.
3. **Tres niveles en la misma tabla** (`level`):
   - `capitulo` — 4 dígitos con millar exacto (`1000`, `2000`… `9000`).
   - `concepto` — 4 dígitos terminados en `00` (`1100`, `2100`…).
   - `partida` — 3 dígitos (`111`, `113`, `211`…). **Solo este nivel se puede
     asignar a una orden de compra.**
4. **Bolsa global.** Una partida tiene un único presupuesto disponible y lo
   consume cualquier departamento. El departamento de la orden es **centro de
   costo**, no dueño del presupuesto. En consecuencia:
   - desaparece el reporte «presupuesto por departamento»;
   - el disponible de una partida es la suma de lo comprometido por todos los
     departamentos que la han usado.

---

## 2. Catálogo de referencia (ejercicio 2026)

Origen: *Presupuesto de Egresos 2026 — Lagos de Moreno, Jalisco*.

| Dato | Valor |
|---|---|
| Total general | $1,024,569,217.00 |
| Capítulos | 8 |
| Conceptos | 39 |
| Partidas genéricas | 121 |

Ficheros:

- `database/data/presupuesto-2026.json` — catálogo estructurado (**generado**).
- `database/data/presupuesto-2026.txt` — texto normalizado del PDF (procedencia).
- `tools/extraer-presupuesto.js` — extractor y validador (Node, sin dependencias).

La herramienta **valida** que la suma de las partidas cuadre con el total, que
cada concepto cuadre con la suma de sus partidas y que cada capítulo cuadre con
la suma de sus conceptos. Si algo no cuadra, no escribe el JSON.

Jerarquía por convención de códigos: `111 → 1100 → 1000`; `211 → 2100 → 2000`.

---

## 3. Esquema de `pv_budget_items` (nuevo)

```sql
CREATE TABLE pv_budget_items (
    id                INT UNSIGNED NOT NULL AUTO_INCREMENT,
    fiscal_year_id    INT UNSIGNED NOT NULL,
    level             VARCHAR(16) NOT NULL DEFAULT 'partida',
    item_number       VARCHAR(40) NOT NULL,
    description       VARCHAR(255) NOT NULL,
    chapter_code      VARCHAR(10) NULL,   -- 1000 (capítulo)
    concept_code      VARCHAR(10) NULL,   -- 1100 (concepto); solo en nivel partida
    authorized_budget DECIMAL(15,4) NOT NULL DEFAULT 0.0000,
    amplified_budget  DECIMAL(15,4) NOT NULL DEFAULT 0.0000,
    reduced_budget    DECIMAL(15,4) NOT NULL DEFAULT 0.0000,
    spent_amount      DECIMAL(15,4) NOT NULL DEFAULT 0.0000,
    status            VARCHAR(20) NOT NULL DEFAULT 'activa',
    created_at        DATETIME NOT NULL,
    updated_at        DATETIME NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_budget_item (fiscal_year_id, item_number),
    KEY idx_budget_items_year (fiscal_year_id),
    KEY idx_budget_items_level (level),
    KEY idx_budget_items_chapter (chapter_code),
    KEY idx_budget_items_status (status)
);
```

Se elimina `department_id` y su clave foránea `fk_budget_items_department`
(`ON DELETE RESTRICT`). `pv_budget_movements` y `pv_budget_authorizations` no
cambian: siguen apuntando a `budget_item_id`.

`purchase_orders` **no cambia**: sigue con `department_id` **y**
`budget_item_id`. Ahora son dos dimensiones independientes: el centro de costo
y la bolsa presupuestal.

`warehouse_vouchers` tampoco cambia: `budget_key` sigue siendo el texto
desnormalizado `código - descripción` del C.O.G.

### 3.1 Quién consumió de cada partida (`pv_budget_item_departments`)

El presupuesto de una partida es global, pero el sistema registra **quién
consumió de cada bolsa**:

```
budget_item_id     partida genérica
department_id      centro de costo que consumió
fiscal_year_id     ejercicio
committed_amount   importe comprometido por ese departamento
orders_count       número de órdenes
```

Único por `(budget_item_id, department_id)`. Se mantiene en la **misma
transacción** que el movimiento de la partida: emitir suma, cancelar resta.
Cumple el invariante:

```
SUMA(committed_amount de una partida) = partida.spent_amount
```

Las órdenes emitidas son la fuente de verdad: la herramienta
`tools/recalcular-gasto-departamentos.php` reconstruye la tabla desde ellas
(`--verificar` solo compara).

**Trampa:** `orders_count` es una columna SIN signo: no admite un delta
negativo. `BudgetDepartmentService::register()` usa dos sentencias distintas
(una para sumar y otra para restar) en lugar de un `INSERT … ON DUPLICATE KEY
UPDATE` con valor negativo.

---

## 4. Reglas de los saldos

| Concepto | Fórmula |
|---|---|
| vigente | `autorizado + ampliaciones - reducciones` |
| comprometido | `spent_amount` |
| disponible | `vigente - comprometido` |

Para `level = 'partida'` se usan sus propias columnas.
Para `level = 'concepto'` o `'capitulo'` los saldos son **agregados de las
partidas hijas**:

- concepto → suma de las partidas con `concept_code = código del concepto`
- capítulo → suma de las partidas con `chapter_code = código del capítulo`

Los **movimientos** (ampliación, reducción, compromiso, liberación) se registran
**siempre sobre una partida de nivel `partida`**. No se admiten
ampliaciones/reducciones sobre capítulos ni conceptos: si se admitieran, el
saldo agregado dejaría de cuadrar con la suma de sus hijas.

El importe del PDF se guarda en `authorized_budget` de las tres filas (capítulo,
concepto y partida). Para los niveles agregados ese importe es **informativo**:
el saldo real es el de la suma de las hijas. El importador verifica que
coincidan.

---

## 5. Contrato de datos entre capas

### 5.1 `PartidaRepository` (API nueva)

```php
// Listado paginado. Filtros: q, level, chapter_code, fiscal_year_id, status
public static function paginate(array $filters, int $page, int $perPage, string $sort = 'item_number', string $dir = 'asc'): array;

// Todas las partidas de nivel 'partida' del ejercicio (para el formulario de orden).
public static function available(?int $fiscalYearId = null, ?string $search = null): array;

// Hijos de un capítulo o concepto.
public static function byChapter(string $chapterCode, ?int $fiscalYearId = null): array;

// Ficha con nombres de capítulo y concepto resueltos.
public static function find(int $id): ?array;

// Unicidad (ejercicio, número de partida).
public static function findByNumber(int $fiscalYearId, string $itemNumber, ?int $exceptId = null): ?array;

public static function create(array $data): int;   // level, item_number, description, chapter_code, concept_code, authorized_budget, status
public static function update(int $id, array $data): int;
public static function delete(int $id): int;
public static function decorate(array $row): array; // añade vigente, comprometido, disponible, used_percent, warning_level, level_label
```

Toda fila devuelta incluye además:

```
level        : 'capitulo' | 'concepto' | 'partida'
level_label  : 'Capítulo' | 'Concepto' | 'Partida'
chapter_code / chapter_name : 1000 / MATERIALES Y SUMINISTROS
concept_code / concept_name : 2100 / MATERIALES DE ADMINISTRACIÓN…
```

### 5.2 `BudgetService`

```php
public static function find(int $id): ?array;      // sin JOIN a departments; trae chapter_name y concept_name
public static function lock(int $id): array;
public static function vigente(array $item): string;      // agregado si el nivel no es 'partida'
public static function comprometido(array $item): string;
public static function disponible(array $item): string;
public static function usedPercent(array $item): float;
public static function amplify(int $id, string $amount, string $reason, ?int $userId = null): array;
public static function reduce(int $id, string $amount, string $reason, ?int $userId = null): array;
```

`amplify()` y `reduce()` **rechazan** con 422 las filas que no sean de nivel
`partida`: «Solo las partidas genéricas admiten ampliación o reducción».

### 5.3 Endpoints

| Endpoint | Cambio |
|---|---|
| `GET /api/ordenes/partidas` | **Ya no exige `department_id`.** Acepta `q`, `fiscal_year_id`. Devuelve partidas de nivel `partida` con saldos, agrupables por capítulo. |
| `GET /api/partidas` | Filtros `q`, `level`, `chapter_code`, `fiscal_year_id`, `status` (ya no `department_id`). |
| `GET /api/partidas/disponibles` | Ahora devuelve **partidas genéricas** de todos los capítulos. |
| `POST /api/partidas` | Campos: `fiscal_year_id`, `level`, `item_number`, `description`, `chapter_code`, `concept_code`, `authorized_budget`, `status`. **Sin `department_id`.** |
| `PUT /api/partidas/{id}` | Igual que el alta. |
| `GET /api/departamentos/{id}/partidas` | **Se elimina**: el concepto ya no existe. |

### 5.4 Formato de la respuesta de partidas (JSON)

```json
{
  "id": 42,
  "item_number": "211",
  "description": "Materiales, útiles y equipos menores de oficina",
  "level": "partida",
  "level_label": "Partida",
  "chapter_code": "2000",
  "chapter_name": "MATERIALES Y SUMINISTROS",
  "concept_code": "2100",
  "concept_name": "MATERIALES DE ADMINISTRACIÓN, EMISIÓN DE DOCUMENTOS Y ARTÍCULOS OFICIALES",
  "authorized_budget": "7928504.0000",
  "vigente": "7928504.00",
  "comprometido": "0.00",
  "disponible": "7928504.00",
  "used_percent": 0,
  "fiscal_year": 2026,
  "status": "activa"
}
```

---

## 6. Reglas de la orden de compra

1. Se elige **departamento** (centro de costo, obligatorio) **y partida**
   (bolsa presupuestal, obligatoria) por separado.
2. La partida debe ser de nivel `partida`. Un capítulo o un concepto no se
   pueden asignar a una orden.
3. **Ya no existe** la regla «la partida presupuestal no pertenece al
   departamento seleccionado».
4. El `fiscal_year_id` de la orden se sigue derivando de la partida.
5. El compromiso y la liberación siguen funcionando igual: se mueven los
   saldos de la partida.
6. Al emitir, el importe también se registra en el desglose por departamento
   (§3.1); al cancelar, se devuelve.

---

## 6 bis. Reportes que responden «¿qué se compró?»

- **`articulos_comprados`** — «Artículos y servicios comprados». Una fila por
  concepto comprado: folio, fecha, proveedor, departamento, partida,
  descripción, cantidad, unidad, precio unitario, IVA e importe. Admite
  **búsqueda por texto libre** (`q`) sobre la descripción: «llantas rin 15».
  Solo cuenta las órdenes **emitidas**: un borrador no es una compra.
- **`gasto_departamento_partida`** — «Gasto acumulado por departamento y
  partida». Lee el desglose (§3.1): departamento, partida, capítulo, órdenes,
  comprometido, vigente de la partida y el **% que representa ese
  departamento** de la bolsa. Es acumulado del ejercicio: no lo acota el rango
  de fechas.
- **`gastado_partida_departamento`** — el mismo desglose calculado en vivo
  desde las órdenes emitidas, con los filtros de fecha y proveedor.

Los tres están en el catálogo de `ReportCatalog` (lo consumen el módulo de
Reportes y el asistente de IA) y en las herramientas `articulos_comprados` y
`gasto_por_departamento_partida` de la IA.

---

## 7. Qué cambia en cada archivo

| Archivo | Cambio |
|---|---|
| `database/migrations/2026_10_22_000008_global_budget_items.php` | **nuevo** — quita `department_id`, añade `level`, `chapter_code`, `concept_code`, nueva unicidad |
| `database/data/presupuesto-2026.json` / `.txt` | **nuevos** — catálogo |
| `tools/extraer-presupuesto.js` | **nuevo** — extractor y validador |
| `tools/cargar-presupuesto.php` | **nuevo** — importa el catálogo al ejercicio; `--limpiar` deja la base vacía de partidas, órdenes y vales |
| `app/Repositories/PartidaRepository.php` | reescrito (contrato 5.1) |
| `app/Repositories/OrdenRepository.php` | `budgetItems()` sin filtro por departamento |
| `app/Repositories/DepartamentoRepository.php` | fuera `items_count` y la dependencia con `budget_items` |
| `app/Repositories/ValeRepository.php` | sin cambios de estructura |
| `app/Services/BudgetService.php` | saldos por nivel (contrato 5.2) |
| `app/Services/BudgetCatalog.php` | **nuevo** — carga idempotente del catálogo oficial |
| `app/Services/OrderWorkflowService.php` | la consulta de la orden deja de unir `departments` por la partida |
| `app/Services/VoucherService.php` | `budgetKey()` sin el JOIN a `departments` |
| `app/Services/ReportCatalog.php` | `presupuesto_departamento` → `presupuesto_capitulo`; se quitan columnas de departamento de los reportes presupuestales |
| `app/Services/AI/AiTools.php`, `AiReportService.php` | `presupuesto_por_departamento` → `presupuesto_por_capitulo` |
| `app/Controllers/PartidasController.php` | sin departamento; niveles y capítulos |
| `app/Controllers/OrdenesController.php` | la partida se busca en el catálogo global |
| `app/Controllers/DepartamentosController.php` | fuera el endpoint de partidas del departamento |
| `app/Controllers/DashboardController.php` | «presupuesto por departamento» → «por capítulo» |
| `app/Views/partidas/*` | niveles, capítulos, sin departamento |
| `app/Views/ordenes/*` | selección de partida global |
| `app/Views/departamentos/index.php` | fuera el modal de partidas del departamento |
| `app/Views/dashboard/index.php`, `reportes/index.php` | etiquetas y columnas nuevas |
| `public/assets/js/partidas.js`, `ordenes.js`, `departamentos.js` | sin departamento; agrupado por capítulo |
| `resources/document-templates/purchase-order.php` | el PDF imprime capítulo, concepto y partida |
| `resources/document-templates/warehouse-voucher.php` | el C.O.G. conserva código y descripción |
| `tools/datos-demo.php` | las partidas de demostración usan códigos del catálogo oficial |
| `tests/*` | se adaptan al modelo global y se añaden casos nuevos |
