# Centros de costo en el módulo de pagos — diseño

Fecha: 2026-09-17
Estado: aprobado por el usuario, pendiente de plan de implementación

## 1. Contexto

`CentroCosto` (`src/Entity/CentroCosto.php`) ya existe como catálogo simple
(código, nombre, activo, escopeado por `cliente`) con su propio CRUD
(`CentroCostoController`, `templates/centro_costo/*`). Sin embargo, no está
conectado a nada:

- El único punto de uso es un FK opcional en `DocumentoCxpDet` (línea de
  factura), pero **nunca se asigna**: el catálogo se carga en
  `DocumentoController` (líneas 287-293 y 419-425) pero no se renderiza en
  ningún formulario, y `setCentroCosto()` no se llama desde ningún sitio del
  código. Es, en la práctica, código muerto.
- No existe en `DocumentoCxp` (cabecera de factura), `Pago`, `PagoAplicacion`
  ni `ChequePago`.
- No aparece en ningún reporte (`ReporteController`) ni en el módulo de
  impresión de cheques/vouchers (`ChequeVoucherPrintDataBuilder`,
  `templates/formato_pago/print.html.twig`).

El usuario quiere tres cosas, en este orden de prioridad de entrega:
1. Reportar gasto por centro de costo.
2. Repartir una misma factura entre varios centros de costo.
3. Que el centro de costo aparezca en el cheque/voucher impreso.

Decisión explícita del usuario: la asignación se hace **a nivel de factura
completa**, no por línea de detalle. El campo ya existente y muerto en
`DocumentoCxpDet` se deja intacto (no se usa, no se borra) — es un tema
aparte, fuera de este alcance.

## 2. Por qué porcentaje y no monto fijo

Una factura puede sufrir retenciones o notas de crédito parciales que
cambian el monto efectivamente pagado. Si el reparto se guardara como montos
fijos, quedaría desalineado en cuanto el monto real pagado difiera del total
original de la factura. Guardando el reparto como **porcentaje**, el cálculo
de "cuánto correspondió a cada centro de costo en este pago" se prorratea
siempre sobre el monto realmente aplicado (`PagoAplicacion.montoAplicado`),
sin importar retenciones, pagos parciales o notas de crédito posteriores.

Este patrón (`porcentaje` DECIMAL(5,2)) ya existe en el codebase
(`Impuesto`, `DocumentoCxpRetencion`, `TipoDocumento.porcentajeRetIva/Isr`),
así que es consistente con las convenciones actuales.

## 3. Modelo de datos

### Entidad nueva: `DocumentoCxpCentroCosto`

Tabla `documento_cxp_centro_costo`, un registro por (factura, centro de
costo):

```php
#[ORM\Entity]
#[ORM\Table(name: 'documento_cxp_centro_costo', uniqueConstraints: [
    new ORM\UniqueConstraint(name: 'UNIQ_DOC_CENTRO', columns: ['documento_id', 'centro_costo_id']),
])]
class DocumentoCxpCentroCosto
{
    #[ORM\Id, ORM\GeneratedValue]
    #[ORM\Column(type: Types::BIGINT, options: ['unsigned' => true])]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: DocumentoCxp::class)]
    #[ORM\JoinColumn(name: 'documento_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private DocumentoCxp $documento;

    #[ORM\ManyToOne(targetEntity: CentroCosto::class)]
    #[ORM\JoinColumn(name: 'centro_costo_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
    private CentroCosto $centroCosto;

    #[ORM\Column(type: Types::DECIMAL, precision: 5, scale: 2)]
    private string $porcentaje;
}
```

- `onDelete: RESTRICT` en `centro_costo_id`: no se puede borrar/desactivar
  (a nivel de base de datos) un centro de costo que ya está en uso en una
  factura — evita huérfanos silenciosos. `CentroCostoController` deberá
  seguir permitiendo "desactivar" (ya tiene el flag `activo`) en vez de
  borrar, que es el patrón que ya usa el resto del sistema (`FormaPago`,
  `FormatoImpresionPago`, etc.).
  **Nota de compatibilidad:** `CentroCostoController::delete()`
  (`src/Controller/CentroCostoController.php:58-67`) hoy hace un borrado
  físico (`$em->remove()`) sin verificar uso, porque hoy nada lo referencia.
  En cuanto exista al menos una factura con centro de costo asignado, ese
  mismo borrado va a chocar con la restricción de la FK y lanzar una
  `ForeignKeyConstraintViolationException` sin capturar. Como parte de la
  Fase 1 hay que envolver ese `remove()` en un try/catch que muestre "No se
  puede eliminar: esta en uso, desactivalo en su lugar" — mismo patrón que
  ya usan otros catálogos del sistema al toparse con una FK en uso.
- `onDelete: CASCADE` en `documento_id`: si se borra una factura (poco común,
  pero posible), su reparto de centros de costo se borra con ella.
- Sin campo `cliente`/`sitio` propio: el tenant se deriva siempre a través de
  `documento.cliente`/`documento.sitio`, igual que `DocumentoCxpDet` (no
  necesita su propio scoping, ya que nunca se consulta de forma aislada sin
  pasar por su factura).

### Migración

Nueva migración `VersionYYYYMMDDHHMMSS` (timestamp del día de
implementación) creando la tabla de arriba con sus FKs, índice en
`documento_id`, y el `UNIQUE(documento_id, centro_costo_id)`.

### Validación (a nivel de aplicación, no de base de datos)

- La suma de `porcentaje` para un mismo `documento_id` debe ser exactamente
  `100.00` si existe al menos un registro. Cero registros es válido (factura
  sin centro de costo asignado — compatibilidad con facturas existentes).
- No se permite un mismo `centro_costo_id` repetido para la misma factura
  (ya lo impide el `UNIQUE`, pero se valida antes de llegar a la BD para dar
  un mensaje de error claro, siguiendo el patrón de
  `ChequePagoService::existsDuplicate()`).
- `porcentaje` debe ser mayor que 0 y menor o igual a 100 por fila.

## 4. UI de asignación (factura)

En el formulario de captura/edición de factura (`templates/documento/*`,
`DocumentoController`), sección nueva "Centros de costo", con el mismo
patrón visual de filas repetibles que ya usa el diseñador de formatos de
pago para campos (agregar fila, quitar fila, cada fila = selector de centro
de costo + input de porcentaje). Reglas de UX:

- Opcional: la factura puede guardarse sin ninguna fila.
- Si el usuario agrega una sola fila, se autocompleta a 100%.
- Contador en vivo: "100% asignado ✓" en verde, o "Falta X% / Sobra X%" en
  rojo si no cuadra — bloquea el guardado hasta que sume 100% (o esté vacío).
- El catálogo de centros de costo activos ya se carga en
  `DocumentoController` (líneas 287-293) — se reutiliza tal cual.

## 5. Cómo se prorratea hacia los pagos

No se agrega ningún campo nuevo a `Pago`, `PagoAplicacion` ni `ChequePago`.
El reparto por centro de costo de un pago aplicado a una factura se calcula
al vuelo, nunca se almacena:

```
monto_del_centro_en_esta_aplicacion = PagoAplicacion.montoAplicado
                                      × (DocumentoCxpCentroCosto.porcentaje / 100)
```

Esto se centraliza en un servicio nuevo, `CentroCostoAllocationService`,
con un método:

```php
/** @return list<array{centroCosto: CentroCosto, monto: string}> */
public function allocateForAplicacion(PagoAplicacion $aplicacion): array
```

que:
1. Busca los `DocumentoCxpCentroCosto` de `$aplicacion->getDocumento()`.
2. Si no hay ninguno, retorna `[]` (factura sin clasificar — el reporte y el
   voucher simplemente no muestran nada para ese pago, no es un error).
3. Si hay, retorna un array con el monto prorrateado por centro, redondeado
   a 2 decimales (con el ajuste de redondeo aplicado a la última fila para
   que la suma cuadre exactamente con `montoAplicado`, mismo patrón que ya
   se usa para restos en cálculos de impuestos).

Este servicio es el único punto de cálculo, usado tanto por el reporte
(sección 6) como por el builder de impresión (sección 7) — evita duplicar la
lógica de prorrateo en dos lugares.

## 6. Reporte "Gasto por centro de costo"

Nuevo endpoint en `ReporteController` (mismo archivo, seccion 924+ ya tiene
`rep_resumen_prov_pagos` como el patrón más cercano — filtra `Pago` por
rango de fecha y estado no-anulado, luego trae sus `PagoAplicacion`):

```
#[Route('/gasto-centro-costo', name: 'rep_gasto_centro_costo', methods: ['GET'])]
#[IsGranted('ROLE_LECTURA')]
```

Filtros: rango de fecha (por `fechaPago`), centro de costo (opcional),
proveedor (opcional). Para cada `PagoAplicacion` en rango, se llama
`CentroCostoAllocationService::allocateForAplicacion()` y se acumula por
centro de costo. Salida: tabla con centro de costo, total pagado, % del
total del período, más exportación a Excel (mismo patrón `export` que ya
usan los otros reportes de este controller). Fila aparte "Sin centro de
costo asignado" para lo no clasificado, para que el total del reporte
siempre cuadre con el total pagado del período.

## 7. Impresión en el cheque/voucher

Se agrega un tipo de campo nuevo al diseñador de formatos, siguiendo
exactamente el patrón de `detalle_facturas_tabla` (tabla) en vez de
`monto_letras` (texto simple), ya que un pago puede repartirse entre varios
centros de costo. Esto toca los mismos 4 puntos que ya identificamos como
rígidos en la revisión anterior del módulo:

1. `FormatoImpresionPagoController::FIELD_CATALOG` — nueva entrada
   `centro_costo_tabla => 'Tabla de centros de costo'`.
2. `assets/ui/voucher-format-designer.mjs` —
   `VOUCHER_FIELD_GROUPS['centro_costo_tabla'] = 'voucher'`, entrada en
   `VOUCHER_SAMPLE_VALUES`/muestra de tabla para la vista previa del
   diseñador (mismo patrón que `buildInvoiceTablePreview`).
3. `templates/formato_pago/print.html.twig` — nueva rama
   `{% elseif campo.campo == 'centro_costo_tabla' %}` que renderiza una
   tabla (centro de costo, monto) usando
   `values.centro_costo_detalle` (ver punto 4), con el mismo mecanismo de
   `field--invoice-detail` / paginación a página de continuación si no cabe.
4. `ChequeVoucherPrintDataBuilder::build()` — nueva clave `centro_costo_detalle`
   en el array de `values`, construida iterando las `PagoAplicacion` del
   pago/cheque y llamando `CentroCostoAllocationService::allocateForAplicacion()`
   por cada una, agregando por centro de costo (un cheque puede cubrir
   varias facturas con distintos repartos).

Esta es la fase más invasiva por tocar 4 archivos en 2 capas (PHP + JS), tal
como ya se había señalado en la revisión de UX del diseñador.

## 8. Fases de entrega

- **Fase 1 — Fundación (habilita reporte y captura):**
  Entidad + migración + `CentroCostoAllocationService` + UI de asignación en
  el formulario de factura + validación de suma 100% + manejo de la FK en
  `CentroCostoController::delete()` (ver nota de compatibilidad, sección 3).
- **Fase 2 — Reporte:**
  Endpoint `rep_gasto_centro_costo` + vista + exportación a Excel.
- **Fase 3 — Impresión:**
  Nuevo tipo de campo en el diseñador + integración en `print.html.twig` +
  `centro_costo_detalle` en el data builder.

Cada fase es entregable y usable de forma independiente; la Fase 1 por sí
sola ya permite empezar a clasificar facturas aunque el reporte no exista
todavía (los datos quedan capturados desde el día uno).

## 9. Pruebas

- Unitarias: `CentroCostoAllocationService` (prorrateo exacto, ajuste de
  redondeo, caso sin centros de costo asignados, caso con un solo centro al
  100%).
- Unitarias: validación de suma de porcentajes (100% exacto, menor a 100%,
  mayor a 100%, centro de costo duplicado).
- Igual que el resto del módulo (ver hallazgo de la revisión general), no
  hay suite funcional que levante el kernel de Symfony — las pruebas de
  este feature seguirán el mismo patrón unitario ya usado en
  `tests/Unit/*`, sin pretender resolver la falta de cobertura funcional del
  proyecto como parte de este trabajo.

## 10. Fuera de alcance (explícito)

- No se toca el campo muerto `DocumentoCxpDet.centroCosto` (ni se activa ni
  se borra).
- No se agrega reparto de centro de costo a nivel de `PagoAplicacion` ni
  `ChequePago` como dato almacenado — siempre se deriva de la factura.
- No se resuelve la falta de pruebas funcionales/CI del proyecto en general.
