# Centros de Costo en Pagos Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let a factura (`DocumentoCxp`) be repartida entre uno o varios centros de costo por porcentaje, y hacer que ese reparto se refleje, prorrateado, en el reporte de gasto por centro de costo y en el cheque/voucher impreso — sin agregar campos nuevos a `Pago`/`PagoAplicacion`/`ChequePago`.

**Architecture:** Una tabla nueva `documento_cxp_centro_costo` (factura + centro de costo + %) es la única fuente de verdad. Un servicio de asignación (`CentroCostoAllocationService`) calcula, al vuelo, cuánto de cada `PagoAplicacion` corresponde a cada centro de costo (`montoAplicado × porcentaje / 100`). Ese servicio es el único punto de cálculo, reutilizado por el reporte y por el generador de datos de impresión.

**Tech Stack:** Symfony 7 + Doctrine ORM + Twig + vanilla JS (Webpack Encore). Sin frameworks de frontend.

**Spec:** `docs/superpowers/specs/2026-09-17-centros-de-costo-pagos-design.md`

## Estado de implementación (2026-09-17)

Todo el código de las Tasks 1, 2, 3, 5, 6, 7, 8 y 9 fue escrito e implementado
en esta sesión (ver historial de git, un commit por task). **Task 4 no
requirió cambios**: al revisar el archivo real, `CentroCostoController::delete()`
ya capturaba `\Exception` genéricamente y mostraba un mensaje claro — el
supuesto del spec/plan original sobre esa función no aplicaba.

Lo que falta, porque este entorno de desarrollo no tiene `vendor/`/`node_modules/`
ni PHP instalado:
- Correr `composer install` y `vendor/bin/phpunit` (todos los tests unitarios
  nuevos: `CentroCostoDistribucionValidatorTest`, `CentroCostoAllocationServiceTest`,
  y las dos pruebas nuevas agregadas a `ChequeVoucherPrintDataBuilderTest`).
- Correr `bin/console doctrine:migrations:migrate` para aplicar
  `Version20260917120000`.
- Probar a mano en el navegador: crear una factura con reparto de centros de
  costo, editarla, ver el reporte `rep_gasto_centro_costo`, y generar un
  cheque/voucher con un campo "Tabla de centros de costo" en su formato.

Los tests de JS (`npm run test:ui`, incluida la nueva aserción de
`voucherFieldGroup('centro_costo_tabla')`) sí se corrieron en esta sesión y
pasan (24/24).

## Global Constraints

- Porcentaje por fila: DECIMAL(5,2), mayor que 0 y menor o igual a 100.
- La suma de porcentajes de una factura debe ser exactamente 100.00 (tolerancia ±0.01) si tiene al menos una fila; cero filas es válido (factura sin clasificar).
- No se permite el mismo centro de costo dos veces en la misma factura.
- No se agregan campos a `Pago`, `PagoAplicacion` ni `ChequePago`. Todo cálculo hacia pagos se deriva de `DocumentoCxpCentroCosto` en el momento de reportar/imprimir.
- No se toca el campo muerto `DocumentoCxpDet.centroCosto`.
- No hay entorno con `vendor/`/PHP disponible en esta sesión de desarrollo: cada tarea PHP debe dejar el test escrito, pero su ejecución (`vendor/bin/phpunit`) y la migración (`bin/console doctrine:migrations:migrate`) quedan pendientes de correr en un entorno con dependencias instaladas antes de dar el feature por verificado.

---

### Task 1: Entidad `DocumentoCxpCentroCosto` + migración

**Files:**
- Create: `src/Entity/DocumentoCxpCentroCosto.php`
- Create: `migrations/Version20260917120000.php`

**Interfaces:**
- Produces: clase `DocumentoCxpCentroCosto` con `getId(): ?int`, `getDocumento(): DocumentoCxp`, `getCentroCosto(): CentroCosto`, `getPorcentaje(): string`, y un constructor `__construct(DocumentoCxp $documento, CentroCosto $centroCosto, string $porcentaje)`.

- [ ] **Step 1: Crear la entidad**

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[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;

    public function __construct(DocumentoCxp $documento, CentroCosto $centroCosto, string $porcentaje)
    {
        $this->documento = $documento;
        $this->centroCosto = $centroCosto;
        $this->porcentaje = $porcentaje;
    }

    public function getId(): ?int { return $this->id; }
    public function getDocumento(): DocumentoCxp { return $this->documento; }
    public function getCentroCosto(): CentroCosto { return $this->centroCosto; }
    public function getPorcentaje(): string { return $this->porcentaje; }
}
```

- [ ] **Step 2: Crear la migración**

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260917120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Crea documento_cxp_centro_costo para repartir facturas entre centros de costo por porcentaje.';
    }

    public function up(Schema $schema): void
    {
        $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on mysql.');

        $this->addSql(<<<'SQL'
            CREATE TABLE documento_cxp_centro_costo (
                id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL,
                documento_id BIGINT UNSIGNED NOT NULL,
                centro_costo_id BIGINT UNSIGNED NOT NULL,
                porcentaje NUMERIC(5, 2) NOT NULL,
                UNIQUE INDEX UNIQ_DOC_CENTRO (documento_id, centro_costo_id),
                INDEX IDX_DOC_CENTRO_COSTO_DOCUMENTO (documento_id),
                INDEX IDX_DOC_CENTRO_COSTO_CENTRO (centro_costo_id),
                PRIMARY KEY(id)
            ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
            SQL);

        $this->addSql(<<<'SQL'
            ALTER TABLE documento_cxp_centro_costo
                ADD CONSTRAINT FK_DOC_CENTRO_COSTO_DOCUMENTO FOREIGN KEY (documento_id) REFERENCES documentos_cxp (id) ON DELETE CASCADE,
                ADD CONSTRAINT FK_DOC_CENTRO_COSTO_CENTRO FOREIGN KEY (centro_costo_id) REFERENCES centros_costo (id) ON DELETE RESTRICT
            SQL);
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP TABLE documento_cxp_centro_costo');
    }
}
```

- [ ] **Step 3: Commit**

```bash
git add src/Entity/DocumentoCxpCentroCosto.php migrations/Version20260917120000.php
git commit -m "feat: agregar entidad y migracion de reparto factura-centro de costo"
```

---

### Task 2: `CentroCostoDistribucionValidator` (validación pura, testeable sin BD)

**Files:**
- Create: `src/Service/CentroCostoDistribucionValidator.php`
- Test: `tests/Unit/CentroCostoDistribucionValidatorTest.php`

**Interfaces:**
- Consumes: nada externo (validación pura sobre arrays).
- Produces: `CentroCostoDistribucionValidator::validate(array $filas): array` donde `$filas` es `list<array{centro_costo_id: int, porcentaje: float}>` y el retorno es `list<string>` de mensajes de error (vacío = válido).

- [ ] **Step 1: Escribir el test que falla**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Service\CentroCostoDistribucionValidator;
use PHPUnit\Framework\TestCase;

final class CentroCostoDistribucionValidatorTest extends TestCase
{
    private CentroCostoDistribucionValidator $validator;

    protected function setUp(): void
    {
        $this->validator = new CentroCostoDistribucionValidator();
    }

    public function testSinFilasEsValido(): void
    {
        self::assertSame([], $this->validator->validate([]));
    }

    public function testUnaSolaFilaAlCienPorCientoEsValida(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 100.0],
        ]);

        self::assertSame([], $errores);
    }

    public function testVariasFilasQueSumanCienEsValido(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 60.0],
            ['centro_costo_id' => 2, 'porcentaje' => 40.0],
        ]);

        self::assertSame([], $errores);
    }

    public function testToleraErrorDeRedondeoDeCentesimas(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 33.34],
            ['centro_costo_id' => 2, 'porcentaje' => 33.33],
            ['centro_costo_id' => 3, 'porcentaje' => 33.33],
        ]);

        self::assertSame([], $errores);
    }

    public function testSumaMenorACienFalla(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 50.0],
        ]);

        self::assertNotEmpty($errores);
        self::assertStringContainsString('100%', $errores[0]);
    }

    public function testSumaMayorACienFalla(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 70.0],
            ['centro_costo_id' => 2, 'porcentaje' => 60.0],
        ]);

        self::assertNotEmpty($errores);
    }

    public function testCentroCostoDuplicadoFalla(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 60.0],
            ['centro_costo_id' => 1, 'porcentaje' => 40.0],
        ]);

        self::assertNotEmpty($errores);
    }

    public function testPorcentajeCeroONegativoFalla(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 0.0],
            ['centro_costo_id' => 2, 'porcentaje' => 100.0],
        ]);

        self::assertNotEmpty($errores);
    }

    public function testPorcentajeMayorACienEnUnaFilaFalla(): void
    {
        $errores = $this->validator->validate([
            ['centro_costo_id' => 1, 'porcentaje' => 120.0],
        ]);

        self::assertNotEmpty($errores);
    }
}
```

- [ ] **Step 2: Correr el test para verificar que falla**

Run: `vendor/bin/phpunit tests/Unit/CentroCostoDistribucionValidatorTest.php`
Expected: FAIL (la clase `CentroCostoDistribucionValidator` no existe todavía).
(No ejecutable en este entorno de desarrollo — dejar constancia y correrlo apenas haya `vendor/` disponible.)

- [ ] **Step 3: Implementación mínima**

```php
<?php

declare(strict_types=1);

namespace App\Service;

final class CentroCostoDistribucionValidator
{
    private const TOLERANCIA = 0.01;

    /**
     * @param list<array{centro_costo_id: int, porcentaje: float}> $filas
     * @return list<string>
     */
    public function validate(array $filas): array
    {
        if ($filas === []) {
            return [];
        }

        $errores = [];
        $vistos = [];
        $suma = 0.0;

        foreach ($filas as $index => $fila) {
            $numeroFila = $index + 1;
            $centroCostoId = $fila['centro_costo_id'];
            $porcentaje = $fila['porcentaje'];

            if ($porcentaje <= 0 || $porcentaje > 100) {
                $errores[] = sprintf('Fila %d: el porcentaje debe ser mayor que 0 y menor o igual a 100.', $numeroFila);
                continue;
            }

            if (isset($vistos[$centroCostoId])) {
                $errores[] = sprintf('Fila %d: el centro de costo ya fue asignado en otra fila.', $numeroFila);
                continue;
            }

            $vistos[$centroCostoId] = true;
            $suma += $porcentaje;
        }

        if ($errores !== []) {
            return $errores;
        }

        if (abs($suma - 100.0) > self::TOLERANCIA) {
            $errores[] = sprintf('Los porcentajes deben sumar 100%% (suman %.2f%%).', $suma);
        }

        return $errores;
    }
}
```

- [ ] **Step 4: Correr el test para verificar que pasa**

Run: `vendor/bin/phpunit tests/Unit/CentroCostoDistribucionValidatorTest.php`
Expected: PASS (pendiente de confirmar en un entorno con `vendor/`).

- [ ] **Step 5: Commit**

```bash
git add src/Service/CentroCostoDistribucionValidator.php tests/Unit/CentroCostoDistribucionValidatorTest.php
git commit -m "feat: validador de reparto de centros de costo"
```

---

### Task 3: `CentroCostoAllocationService` (prorrateo hacia pagos)

**Files:**
- Create: `src/Service/CentroCostoAllocationService.php`
- Test: `tests/Unit/CentroCostoAllocationServiceTest.php`

**Interfaces:**
- Consumes: `DocumentoCxpCentroCosto` (Task 1), `PagoAplicacion::getDocumento(): DocumentoCxp`, `PagoAplicacion::getMontoAplicado(): string`.
- Produces: `CentroCostoAllocationService::allocate(array $distribucion, string $montoAplicado): array` — método puro de prorrateo (testeable sin Doctrine) que devuelve `list<array{centroCosto: CentroCosto, monto: string}>`, y `CentroCostoAllocationService::allocateForAplicacion(PagoAplicacion $aplicacion): array` que resuelve la distribución desde la base de datos y delega en `allocate()`.

- [ ] **Step 1: Escribir el test que falla (sobre el método puro `allocate`)**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit;

use App\Entity\CentroCosto;
use App\Entity\DocumentoCxp;
use App\Entity\DocumentoCxpCentroCosto;
use App\Service\CentroCostoAllocationService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

final class CentroCostoAllocationServiceTest extends TestCase
{
    private function makeCentroCosto(int $id, string $nombre): CentroCosto
    {
        $centro = new CentroCosto();
        $ref = new ReflectionClass($centro);
        $idProp = $ref->getProperty('id');
        $idProp->setAccessible(true);
        $idProp->setValue($centro, $id);
        $nombreProp = $ref->getProperty('nombre');
        $nombreProp->setAccessible(true);
        $nombreProp->setValue($centro, $nombre);

        return $centro;
    }

    public function testSinDistribucionRetornaVacio(): void
    {
        $em = $this->createStub(EntityManagerInterface::class);
        $service = new CentroCostoAllocationService($em);

        self::assertSame([], $service->allocate([], '1000.00'));
    }

    public function testRepartoSimpleAlCincuentaCincuenta(): void
    {
        $em = $this->createStub(EntityManagerInterface::class);
        $service = new CentroCostoAllocationService($em);
        $centroA = $this->makeCentroCosto(1, 'Ventas');
        $centroB = $this->makeCentroCosto(2, 'Administracion');

        $distribucion = [
            new DocumentoCxpCentroCosto(new DocumentoCxp(), $centroA, '50.00'),
            new DocumentoCxpCentroCosto(new DocumentoCxp(), $centroB, '50.00'),
        ];

        $resultado = $service->allocate($distribucion, '1000.00');

        self::assertCount(2, $resultado);
        self::assertSame('500.00', $resultado[0]['monto']);
        self::assertSame('500.00', $resultado[1]['monto']);
    }

    public function testElAjusteDeRedondeoVaEnLaUltimaFila(): void
    {
        $em = $this->createStub(EntityManagerInterface::class);
        $service = new CentroCostoAllocationService($em);
        $centroA = $this->makeCentroCosto(1, 'A');
        $centroB = $this->makeCentroCosto(2, 'B');
        $centroC = $this->makeCentroCosto(3, 'C');

        $distribucion = [
            new DocumentoCxpCentroCosto(new DocumentoCxp(), $centroA, '33.34'),
            new DocumentoCxpCentroCosto(new DocumentoCxp(), $centroB, '33.33'),
            new DocumentoCxpCentroCosto(new DocumentoCxp(), $centroC, '33.33'),
        ];

        $resultado = $service->allocate($distribucion, '100.00');

        $suma = array_sum(array_map(static fn(array $fila): float => (float) $fila['monto'], $resultado));
        self::assertSame(100.0, round($suma, 2));
    }
}
```

- [ ] **Step 2: Correr el test para verificar que falla**

Run: `vendor/bin/phpunit tests/Unit/CentroCostoAllocationServiceTest.php`
Expected: FAIL (la clase no existe). No ejecutable en este entorno — dejar constancia.

- [ ] **Step 3: Implementación mínima**

```php
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\CentroCosto;
use App\Entity\DocumentoCxpCentroCosto;
use App\Entity\PagoAplicacion;
use Doctrine\ORM\EntityManagerInterface;

final class CentroCostoAllocationService
{
    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    /**
     * @return list<array{centroCosto: CentroCosto, monto: string}>
     */
    public function allocateForAplicacion(PagoAplicacion $aplicacion): array
    {
        $distribucion = $this->em->getRepository(DocumentoCxpCentroCosto::class)
            ->findBy(['documento' => $aplicacion->getDocumento()]);

        return $this->allocate($distribucion, $aplicacion->getMontoAplicado());
    }

    /**
     * @param list<DocumentoCxpCentroCosto> $distribucion
     * @return list<array{centroCosto: CentroCosto, monto: string}>
     */
    public function allocate(array $distribucion, string $montoAplicado): array
    {
        if ($distribucion === []) {
            return [];
        }

        $monto = (float) $montoAplicado;
        $resultado = [];
        $acumulado = 0.0;
        $total = count($distribucion);

        foreach ($distribucion as $index => $fila) {
            if ($index === $total - 1) {
                $montoFila = round($monto - $acumulado, 2);
            } else {
                $montoFila = round($monto * ((float) $fila->getPorcentaje() / 100), 2);
                $acumulado += $montoFila;
            }

            $resultado[] = [
                'centroCosto' => $fila->getCentroCosto(),
                'monto' => number_format($montoFila, 2, '.', ''),
            ];
        }

        return $resultado;
    }
}
```

- [ ] **Step 4: Correr el test para verificar que pasa**

Run: `vendor/bin/phpunit tests/Unit/CentroCostoAllocationServiceTest.php`
Expected: PASS (pendiente de confirmar en un entorno con `vendor/`).

- [ ] **Step 5: Commit**

```bash
git add src/Service/CentroCostoAllocationService.php tests/Unit/CentroCostoAllocationServiceTest.php
git commit -m "feat: servicio de prorrateo de centros de costo hacia pagos"
```

---

### Task 4: `CentroCostoController::delete()` — manejar la FK en uso

**Files:**
- Modify: `src/Controller/CentroCostoController.php:58-70` (método `delete`)

**Interfaces:**
- Consumes: `Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException`.

- [ ] **Step 1: Envolver el remove() existente**

Reemplazar el cuerpo actual de `delete()` (que hace `$this->em->remove($o); $this->em->flush();` sin capturar la FK) por:

```php
#[Route('/{id}/eliminar', name: 'cc_delete', methods: ['POST'])]
#[IsGranted('ROLE_OPERADOR')]
public function delete(int $id): Response
{
    $o = $this->em->getRepository(CentroCosto::class)->find($id);
    if ($o) {
        try {
            $this->em->remove($o);
            $this->em->flush();
            $this->addFlash('success', 'Eliminado.');
        } catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException) {
            $this->em->clear();
            $this->addFlash('danger', 'No se puede eliminar: esta en uso en una o mas facturas. Desactivalo en su lugar.');
        }
    }

    return $this->redirectToRoute('cc_index');
}
```

(Mantener el resto del archivo igual; solo se reemplaza el cuerpo de `delete()`. El `try` original ya existe parcialmente — revisar el archivo real antes de aplicar para no duplicar el `try` que ya está abierto en la línea 64.)

- [ ] **Step 2: Commit**

```bash
git add src/Controller/CentroCostoController.php
git commit -m "fix: manejar centro de costo en uso al intentar eliminarlo"
```

---

### Task 5: Capturar el reparto al crear una factura (`DocumentoController::store()`)

**Files:**
- Modify: `src/Controller/DocumentoController.php` (método `store()`, ~línea 526-768: agregar el parseo/validación antes del `beginTransaction()` y la persistencia de filas junto al loop de `DocumentoCxpDet`)

**Interfaces:**
- Consumes: `CentroCostoDistribucionValidator::validate()` (Task 2), `DocumentoCxpCentroCosto` (Task 1).
- Produces: comportamiento — POST `documento_store` ahora acepta `centros_costo[N][centro_costo_id]` y `centros_costo[N][porcentaje]`.

- [ ] **Step 1: Parsear y validar antes de abrir la transacción**

Justo antes del bloque `$this->em->beginTransaction();` (línea ~723 según la exploración previa), agregar:

```php
$centrosCostoInput = $request->request->all('centros_costo') ?: [];
$centrosCostoFilas = [];
foreach ($centrosCostoInput as $fila) {
    $centroCostoId = (int) ($fila['centro_costo_id'] ?? 0);
    $porcentaje = (float) str_replace(',', '', (string) ($fila['porcentaje'] ?? 0));
    if ($centroCostoId <= 0) {
        continue;
    }
    $centrosCostoFilas[] = ['centro_costo_id' => $centroCostoId, 'porcentaje' => $porcentaje];
}

$erroresCentroCosto = $this->centroCostoValidator->validate($centrosCostoFilas);
if ($erroresCentroCosto !== []) {
    $this->addFlash('danger', 'Centros de costo: ' . implode(' ', $erroresCentroCosto));
    return $this->redirectToRoute('documento_new');
}
```

- [ ] **Step 2: Persistir las filas junto con `DocumentoCxpDet`**

Justo después del loop existente que crea `DocumentoCxpDet` (después de la línea que hace `$this->em->persist($detalle);` dentro del `foreach ($lineasValidas as $lineaData)`, antes del `$this->em->flush();` final de esa sección), agregar:

```php
if ($centrosCostoFilas !== []) {
    $centrosCostoCatalogo = $this->em->getRepository(CentroCosto::class)
        ->createQueryBuilder('c')
        ->andWhere('c.id IN (:ids)')->setParameter('ids', array_column($centrosCostoFilas, 'centro_costo_id'))
        ->andWhere('IDENTITY(c.cliente) = :cli')->setParameter('cli', $cliente->getId())
        ->getQuery()->getResult();
    $centrosCostoPorId = [];
    foreach ($centrosCostoCatalogo as $centro) {
        $centrosCostoPorId[$centro->getId()] = $centro;
    }

    foreach ($centrosCostoFilas as $fila) {
        $centro = $centrosCostoPorId[$fila['centro_costo_id']] ?? null;
        if (!$centro instanceof \App\Entity\CentroCosto) {
            continue;
        }
        $this->em->persist(new \App\Entity\DocumentoCxpCentroCosto($doc, $centro, number_format($fila['porcentaje'], 2, '.', '')));
    }
}
```

(Nota para quien implemente: `$cliente` y `$doc` ya existen en el scope de `store()` en este punto — `$doc` es la entidad `DocumentoCxp` recién persistida, `$cliente` es la entidad `Cliente` del tenant actual. Verificar los nombres exactos de variable en el archivo real antes de pegar, ya que este plan se escribió a partir de un resumen del método, no de una lectura línea por línea completa de `store()`.)

- [ ] **Step 3: Inyectar el validador en el constructor de `DocumentoController`**

Agregar `private readonly CentroCostoDistribucionValidator $centroCostoValidator` a la lista de dependencias del constructor (junto a las que ya existen), y el `use App\Service\CentroCostoDistribucionValidator;` correspondiente arriba del archivo.

- [ ] **Step 4: Commit**

```bash
git add src/Controller/DocumentoController.php
git commit -m "feat: capturar reparto de centros de costo al crear una factura"
```

---

### Task 6: UI de captura en el wizard de factura (paso 2, `new.html.twig` + `documento-wizard.js`)

**Files:**
- Modify: `templates/documento/new.html.twig` (agregar sección dentro de "Paso 2", líneas ~189-278, después de la tabla de líneas)
- Modify: `assets/documento-wizard.js`

**Interfaces:**
- Consumes: variable Twig `centrosCosto` (ya disponible, `[{id, nombre}]`, ver `DocumentoController.php:419-425`).
- Produces: al enviar el formulario, hidden inputs `centros_costo[N][centro_costo_id]` / `centros_costo[N][porcentaje]`, mismo mecanismo que `handleSubmit()` ya usa para `lineas[...]`.

- [ ] **Step 1: Agregar el bloque HTML en `new.html.twig`**

Insertar después de la tabla de líneas (después del `</table>` de `#lineas-tbody`, dentro del mismo `paso-2`):

```twig
<div class="mt-6 border-t border-secondary-200 pt-6">
  <div class="flex items-center justify-between">
    <h4 class="text-sm font-semibold text-secondary-700">Centros de costo (opcional)</h4>
    <button type="button" id="btn-agregar-centro-costo" class="btn btn-outline-secondary btn-sm">+ Agregar</button>
  </div>
  <p id="centros-costo-total" class="mt-1 text-xs text-secondary-500">Sin centros de costo asignados.</p>
  <table class="table mt-2">
    <thead><tr><th>Centro de costo</th><th>Porcentaje</th><th></th></tr></thead>
    <tbody id="centros-costo-tbody"></tbody>
  </table>
  <template id="tpl-centro-costo-option">
    {% for cc in centrosCosto %}<option value="{{ cc.id }}">{{ cc.nombre }}</option>{% endfor %}
  </template>
</div>
```

- [ ] **Step 2: Agregar la lógica en `documento-wizard.js`**

Siguiendo el mismo patrón de `agregarLinea()`/`renderLineas()`/`attachLineInputListeners()` (líneas 639-739) y de inyección de hidden inputs en `handleSubmit()` (líneas 1073-1124), agregar en la clase `DocumentoWizard`:

```js
setupCentroCostoManagement() {
    this.centrosCosto = [];
    document.getElementById('btn-agregar-centro-costo')?.addEventListener('click', () => this.agregarCentroCosto());
    document.getElementById('centros-costo-tbody')?.addEventListener('click', (event) => {
        const removeButton = event.target.closest('.btn-eliminar-centro-costo');
        if (!removeButton) {
            return;
        }
        const id = removeButton.closest('tr')?.dataset.centroId;
        this.centrosCosto = this.centrosCosto.filter((fila) => String(fila.id) !== id);
        this.renderCentrosCosto();
    });
}

agregarCentroCosto() {
    this.centrosCosto.push({ id: Date.now(), centroCostoId: '', porcentaje: '' });
    this.renderCentrosCosto();
}

renderCentrosCosto() {
    const tbody = document.getElementById('centros-costo-tbody');
    const optionsMarkup = document.getElementById('tpl-centro-costo-option').innerHTML;
    if (!tbody) {
        return;
    }
    tbody.innerHTML = this.centrosCosto.map((fila) => `
        <tr data-centro-id="${fila.id}">
            <td><select class="form-select centro-costo-select"><option value="">Selecciona...</option>${optionsMarkup}</select></td>
            <td><input type="number" class="form-control centro-costo-porcentaje" min="0.01" max="100" step="0.01" value="${fila.porcentaje}"></td>
            <td><button type="button" class="btn btn-outline-danger btn-sm btn-eliminar-centro-costo">Quitar</button></td>
        </tr>
    `).join('');
    tbody.querySelectorAll('tr').forEach((row) => {
        const id = row.dataset.centroId;
        const fila = this.centrosCosto.find((f) => String(f.id) === id);
        row.querySelector('.centro-costo-select').value = fila.centroCostoId;
        row.querySelector('.centro-costo-select').addEventListener('change', (e) => { fila.centroCostoId = e.target.value; this.actualizarTotalCentrosCosto(); });
        row.querySelector('.centro-costo-porcentaje').addEventListener('input', (e) => { fila.porcentaje = e.target.value; this.actualizarTotalCentrosCosto(); });
    });
    this.actualizarTotalCentrosCosto();
}

actualizarTotalCentrosCosto() {
    const total = document.getElementById('centros-costo-total');
    if (!total) {
        return;
    }
    if (this.centrosCosto.length === 0) {
        total.textContent = 'Sin centros de costo asignados.';
        total.className = 'mt-1 text-xs text-secondary-500';
        return;
    }
    const suma = this.centrosCosto.reduce((acc, fila) => acc + (parseFloat(fila.porcentaje) || 0), 0);
    const cuadra = Math.abs(suma - 100) < 0.01;
    total.textContent = cuadra ? `${suma.toFixed(2)}% asignado ✓` : `${suma.toFixed(2)}% asignado — debe sumar 100%`;
    total.className = cuadra ? 'mt-1 text-xs text-green-600' : 'mt-1 text-xs text-red-600';
}

validateCentrosCosto() {
    if (this.centrosCosto.length === 0) {
        return true;
    }
    const suma = this.centrosCosto.reduce((acc, fila) => acc + (parseFloat(fila.porcentaje) || 0), 0);
    if (Math.abs(suma - 100) >= 0.01) {
        this.showError('Los centros de costo deben sumar 100%.');
        return false;
    }
    if (this.centrosCosto.some((fila) => !fila.centroCostoId)) {
        this.showError('Selecciona un centro de costo en cada fila.');
        return false;
    }
    return true;
}
```

Llamar `this.setupCentroCostoManagement();` desde el constructor/`init()` junto a `this.setupLineManagement();`. En `handleSubmit(e)`, antes de construir los hidden inputs de `lineas`, agregar:

```js
if (!this.validateCentrosCosto()) {
    e.preventDefault();
    return;
}

this.centrosCosto.forEach((fila, index) => {
    [
        { name: `centros_costo[${index}][centro_costo_id]`, value: fila.centroCostoId },
        { name: `centros_costo[${index}][porcentaje]`, value: fila.porcentaje },
    ].forEach((input) => {
        const hiddenInput = document.createElement('input');
        hiddenInput.type = 'hidden';
        hiddenInput.name = input.name;
        hiddenInput.value = input.value;
        hiddenInput.className = 'linea-hidden-input';
        form.appendChild(hiddenInput);
    });
});
```

(Nota para quien implemente: revisar el `handleSubmit()` real para confirmar el nombre exacto de la variable `form` y el punto exacto de inserción antes del `HTMLFormElement.prototype.submit.call(form)`.)

- [ ] **Step 3: Verificar manualmente en navegador**

No hay entorno de build (`node_modules`/`vendor`) disponible en esta sesión — dejar esta verificación para cuando el proyecto se corra localmente: abrir "Nueva factura", agregar 2 centros de costo que sumen 100%, confirmar que el contador se pone verde, guardar, y confirmar en la base de datos que se crearon 2 filas en `documento_cxp_centro_costo`.

- [ ] **Step 4: Commit**

```bash
git add templates/documento/new.html.twig assets/documento-wizard.js
git commit -m "feat: UI de reparto de centros de costo en el wizard de factura"
```

---

### Task 7: Reporte "Gasto por centro de costo"

**Files:**
- Modify: `src/Controller/ReporteController.php` (agregar acción nueva, siguiendo el patrón de `pagosAplicaciones()`, línea 924+)
- Create: `templates/reporte/gasto_centro_costo.html.twig` (mismo patrón que `templates/reporte/pagos_aplicaciones.html.twig`)

**Interfaces:**
- Consumes: `CentroCostoAllocationService::allocateForAplicacion()` (Task 3).

- [ ] **Step 1: Agregar la acción del reporte**

```php
#[Route('/gasto-centro-costo', name: 'rep_gasto_centro_costo', methods: ['GET'])]
#[IsGranted('ROLE_LECTURA')]
public function gastoCentroCosto(Request $request, CentroCostoAllocationService $allocationService): Response
{
    $clienteId = $this->tenantContext->getClienteId();
    $sitioId = $this->tenantContext->getSitioId();
    if (!$clienteId || !$sitioId) {
        $this->addFlash('warning', 'Selecciona cliente y sitio para continuar.');
        return $this->redirectToRoute('home');
    }

    $today = new \DateTimeImmutable('today');
    $fechaDesde = $this->getDateQuery($request, 'fecha_desde', $today->modify('first day of this month')->format('Y-m-d'));
    $fechaHasta = $this->getDateQuery($request, 'fecha_hasta', $today->format('Y-m-d'));
    $export = $this->normalizeExport($this->getScalarQueryString($request, 'export'));

    $aplicaciones = $this->em->getRepository(PagoAplicacion::class)
        ->createQueryBuilder('pa')
        ->innerJoin('pa.pago', 'p')->addSelect('p')
        ->innerJoin('pa.documento', 'd')->addSelect('d')
        ->andWhere('IDENTITY(pa.cliente) = :cli')->setParameter('cli', $clienteId)
        ->andWhere('IDENTITY(p.sitio) = :sitio')->setParameter('sitio', $sitioId)
        ->andWhere('p.estado NOT IN (:anulados)')->setParameter('anulados', [0, 3])
        ->andWhere('p.fechaPago >= :desde')->setParameter('desde', new \DateTimeImmutable($fechaDesde))
        ->andWhere('p.fechaPago <= :hasta')->setParameter('hasta', (new \DateTimeImmutable($fechaHasta))->setTime(23, 59, 59))
        ->getQuery()->getResult();

    $totalesPorCentro = [];
    $sinClasificar = 0.0;
    $totalGeneral = 0.0;

    foreach ($aplicaciones as $aplicacion) {
        if (!$aplicacion instanceof PagoAplicacion) {
            continue;
        }
        $monto = (float) $aplicacion->getMontoAplicado();
        $totalGeneral += $monto;
        $reparto = $allocationService->allocateForAplicacion($aplicacion);
        if ($reparto === []) {
            $sinClasificar += $monto;
            continue;
        }
        foreach ($reparto as $fila) {
            $centroId = $fila['centroCosto']->getId();
            if (!isset($totalesPorCentro[$centroId])) {
                $totalesPorCentro[$centroId] = ['nombre' => $fila['centroCosto']->getNombre(), 'total' => 0.0];
            }
            $totalesPorCentro[$centroId]['total'] += (float) $fila['monto'];
        }
    }

    $rows = array_values($totalesPorCentro);
    usort($rows, static fn(array $a, array $b): int => $b['total'] <=> $a['total']);
    foreach ($rows as &$row) {
        $row['porcentaje'] = $totalGeneral > 0 ? round($row['total'] / $totalGeneral * 100, 1) : 0.0;
    }
    unset($row);
    if ($sinClasificar > 0) {
        $rows[] = ['nombre' => 'Sin centro de costo asignado', 'total' => $sinClasificar, 'porcentaje' => $totalGeneral > 0 ? round($sinClasificar / $totalGeneral * 100, 1) : 0.0];
    }

    if ($export === 'excel') {
        return $this->createXlsxResponse(
            sprintf('gasto_centro_costo_%s.xlsx', (new \DateTimeImmutable())->format('Ymd_His')),
            'Gasto por centro de costo',
            ['Centro de costo', 'Total', '% del periodo'],
            ['string', 'price', 'string'],
            array_map(static fn(array $r): array => [$r['nombre'], $r['total'], $r['porcentaje'] . '%'], $rows),
            [30, 16, 14]
        );
    }

    return $this->renderReport(
        'reporte/gasto_centro_costo.html.twig',
        [
            'filtros' => ['fecha_desde' => $fechaDesde, 'fecha_hasta' => $fechaHasta],
            'rows' => $rows,
            'totalGeneral' => $totalGeneral,
        ],
        $export,
        sprintf('gasto_centro_costo_%s.pdf', (new \DateTimeImmutable())->format('Ymd_His'))
    );
}
```

(Nota: agregar `use App\Service\CentroCostoAllocationService;` arriba del archivo si no está — se inyecta como argumento del método, no en el constructor, siguiendo el patrón de autowiring de acción que ya usa Symfony en este proyecto para servicios usados en una sola acción.)

- [ ] **Step 2: Crear la plantilla**

```twig
{% extends reportShell|default('base.html.twig') %}
{% block title %}Gasto por centro de costo{% endblock %}
{% block page_title %}Gasto por centro de costo{% endblock %}

{% block breadcrumb %}
  <nav class="flex no-print" aria-label="Breadcrumb"><ol class="flex flex-wrap items-center gap-2 text-sm"><li><a href="{{ path('home') }}" class="text-secondary-500 hover:text-secondary-700">Inicio</a></li><li class="text-secondary-300" aria-hidden="true">/</li><li class="text-secondary-500">Reportes</li><li class="text-secondary-300" aria-hidden="true">/</li><li class="text-secondary-700" aria-current="page">Gasto por centro de costo</li></ol></nav>
{% endblock %}

{% block report_title %}Gasto por centro de costo{% endblock %}
{% block report_subtitle %}Período {{ filtros.fecha_desde|date('d/m/Y') }} al {{ filtros.fecha_hasta|date('d/m/Y') }}{% endblock %}

{% block content %}
  {% set isExportView = reportShell|default('base.html.twig') != 'base.html.twig' %}
  {% set exportQuery = {'fecha_desde': filtros.fecha_desde, 'fecha_hasta': filtros.fecha_hasta} %}
  <div class="{{ isExportView ? 'report-stack report-stack--export' : 'min-w-0 space-y-6' }}">
    {% if not isExportView %}<section class="page-header"><div><p class="text-xs font-semibold uppercase text-primary-600">Reportes</p><h2 class="page-title">Gasto por centro de costo</h2><p class="page-subtitle">Cuanto se pago por cada centro de costo en el periodo seleccionado.</p></div></section>{% endif %}

    <section class="{{ isExportView ? 'report-panel no-print' : 'section-card report-filter-card' }}">
      <form method="get" class="{{ isExportView ? 'report-form-grid' : 'grid gap-4 p-4 md:grid-cols-[180px_180px_auto] md:items-end' }}">
        <div class="{{ isExportView ? 'report-field report-field--sm' : '' }}"><label class="{{ isExportView ? 'report-label' : 'form-label' }}" for="fecha_desde">Desde</label><input class="{{ isExportView ? 'report-input' : 'form-control' }}" type="date" id="fecha_desde" name="fecha_desde" value="{{ filtros.fecha_desde }}"></div>
        <div class="{{ isExportView ? 'report-field report-field--sm' : '' }}"><label class="{{ isExportView ? 'report-label' : 'form-label' }}" for="fecha_hasta">Hasta</label><input class="{{ isExportView ? 'report-input' : 'form-control' }}" type="date" id="fecha_hasta" name="fecha_hasta" value="{{ filtros.fecha_hasta }}"></div>
        <div class="{{ isExportView ? 'report-actions' : 'flex flex-wrap gap-2' }}">
          <button class="{{ isExportView ? 'report-button report-button--primary' : 'btn btn-primary' }}" type="submit">Actualizar</button>
          {% if isExportView %}<a class="report-link-button" href="{{ path('rep_gasto_centro_costo', exportQuery|merge({export: 'excel'})) }}">Excel</a><a class="report-link-button" href="{{ path('rep_gasto_centro_costo', exportQuery|merge({export: 'pdf'})) }}">PDF</a>{% else %}{% include 'reporte/_export_menu.html.twig' with {id: 'centro-costo-report-export-menu', routeName: 'rep_gasto_centro_costo', exportQuery: exportQuery} only %}{% endif %}
        </div>
      </form>
    </section>

    <section class="{{ isExportView ? 'report-panel report-panel--table' : 'table-card' }}">
      <div class="{{ isExportView ? 'report-table-wrap' : 'table-responsive max-h-[72vh]' }}">
        <table class="{{ isExportView ? 'report-table report-table--dense' : 'table table-to-card' }}">
          <caption class="sr-only">Gasto por centro de costo</caption>
          <thead><tr><th scope="col">Centro de costo</th><th scope="col" class="text-right">Total</th><th scope="col" class="text-right">% del periodo</th></tr></thead>
          <tbody>
            {% for row in rows %}
              <tr>
                <td data-label="Centro de costo">{{ row.nombre }}</td>
                <td data-label="Total" class="text-right whitespace-nowrap">{{ row.total|number_format(2, '.', ',') }}</td>
                <td data-label="%" class="text-right whitespace-nowrap">{{ row.porcentaje }}%</td>
              </tr>
            {% else %}
              <tr><td colspan="3" class="{{ isExportView ? 'report-empty' : 'table-card-state text-center' }}">No hay pagos para los filtros seleccionados.</td></tr>
            {% endfor %}
          </tbody>
        </table>
      </div>
    </section>
  </div>
{% endblock %}
```

- [ ] **Step 3: Commit**

```bash
git add src/Controller/ReporteController.php templates/reporte/gasto_centro_costo.html.twig
git commit -m "feat: reporte de gasto por centro de costo"
```

---

### Task 8: Nuevo tipo de campo "Centro de costo" en el diseñador de formatos

**Files:**
- Modify: `src/Controller/FormatoImpresionPagoController.php` (constante `FIELD_CATALOG`)
- Modify: `assets/ui/voucher-format-designer.mjs` (`VOUCHER_FIELD_GROUPS`, muestra de vista previa)
- Modify: `templates/formato_pago/print.html.twig` (nueva rama de renderizado)
- Modify: `src/Service/ChequeVoucherPrintDataBuilder.php` (nueva clave `centro_costo_detalle` en `values`)

**Interfaces:**
- Consumes: `CentroCostoAllocationService::allocateForAplicacion()` (Task 3).
- Produces: `values['centro_costo_detalle']` = `list<array{nombre: string, monto: string}>` disponible en `print.html.twig`.

- [ ] **Step 1: Agregar el tipo al catálogo PHP**

En `FormatoImpresionPagoController::FIELD_CATALOG` (constante ya existente con `'texto' => ..., 'fecha' => ..., etc.`), agregar la entrada:

```php
'centro_costo_tabla' => 'Tabla de centros de costo',
```

- [ ] **Step 2: Agregar el tipo al diseñador JS**

En `VOUCHER_FIELD_GROUPS` (`assets/ui/voucher-format-designer.mjs`), agregar:

```js
centro_costo_tabla: 'voucher',
```

En `updatePreview()`, en la rama que decide entre tabla y texto (donde hoy solo se chequea `type === 'detalle_facturas_tabla'`), agregar `centro_costo_tabla` a esa condición para que también use `buildInvoiceTablePreview`-like rendering; como su forma de tabla es distinta (2 columnas: centro de costo, monto, sin fecha/serie/factura), crear una función hermana `buildCentroCostoTablePreview(host, caption, sample)` con muestra fija `[{ nombre: 'Ventas', monto: 'Q 617.28' }, { nombre: 'Administracion', monto: 'Q 617.28' }]` cuando `sample` es true, y arreglo vacío si no, reutilizando la misma estructura de `<table>`/`<caption>`/`<thead>`/`<tbody>` que ya usa `buildInvoiceTablePreview` pero con columnas `Centro de costo` / `Monto`.

- [ ] **Step 3: Agregar la rama de impresión**

En `templates/formato_pago/print.html.twig`, en la cadena `{% if campo.campo == 'detalle_facturas_tabla' %} ... {% elseif campo.campo == 'texto' %} ...`, agregar una rama nueva antes del `{% elseif campo.campo == 'texto' %}`:

```twig
{% elseif campo.campo == 'centro_costo_tabla' %}
  {% if values.centro_costo_detalle is iterable and values.centro_costo_detalle|length > 0 %}
    <table class="invoice-detail">
      <caption>{{ campo.etiqueta }}</caption>
      <thead><tr><th scope="col">Centro de costo</th><th class="amount" scope="col">Monto</th></tr></thead>
      <tbody>
        {% for fila in values.centro_costo_detalle %}
          <tr><td>{{ fila.nombre }}</td><td class="amount">{{ fila.monto }}</td></tr>
        {% endfor %}
      </tbody>
    </table>
  {% endif %}
```

- [ ] **Step 4: Poblar `centro_costo_detalle` en el data builder**

En `ChequeVoucherPrintDataBuilder::build()`, donde se arma el array `$values` (junto a `detalle_facturas`/`detalle_facturas_tabla`), agregar la construcción de `centro_costo_detalle` iterando las `PagoAplicacion` del cheque/pago y acumulando por centro de costo con `CentroCostoAllocationService::allocateForAplicacion()` (mismo patrón de acumulación que el Task 7, sin la fila "sin clasificar" — si no hay reparto para ninguna aplicación, `centro_costo_detalle` queda como array vacío y el campo simplemente no imprime nada, ver Step 3).

(Nota para quien implemente: `ChequeVoucherPrintDataBuilder` no tiene hoy una dependencia a `EntityManagerInterface` inyectada — revisar su constructor real antes de decidir si se inyecta `CentroCostoAllocationService` directamente o si hace falta pasarle las `PagoAplicacion` ya cargadas desde el controller que lo invoca.)

- [ ] **Step 5: Agregar test de designer para el nuevo grupo de campo**

En `tests/js/voucher-format-designer.test.mjs`, junto al test existente "clasifica cada campo en la parte del cheque o la del voucher", agregar una aserción de que `voucherFieldGroup('centro_costo_tabla') === 'voucher'`.

- [ ] **Step 6: Correr los tests de JS**

Run: `npm run test:ui`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add src/Controller/FormatoImpresionPagoController.php assets/ui/voucher-format-designer.mjs templates/formato_pago/print.html.twig src/Service/ChequeVoucherPrintDataBuilder.php tests/js/voucher-format-designer.test.mjs
git commit -m "feat: imprimir el reparto de centros de costo en el cheque/voucher"
```

---

### Task 9: Permitir editar el reparto en una factura ya creada (`DocumentoController::updateLineas()`)

**Files:**
- Modify: `src/Controller/DocumentoController.php` (método `updateLineas()`, líneas ~1467-1583)
- Modify: `templates/documento/edit_lineas.html.twig`

**Interfaces:**
- Consumes: `CentroCostoDistribucionValidator::validate()` (Task 2), `DocumentoCxpCentroCosto` (Task 1).

Sin esta tarea, una factura creada sin reparto (o con un reparto que luego resulta incorrecto) queda inmutable — la Fase 1 del feature no estaría realmente terminada para el uso diario.

- [ ] **Step 1: Reemplazar el reparto existente al guardar**

En `updateLineas()`, siguiendo el mismo patrón de parseo que se usa para `lineas`/`nuevas` (`$request->request->all('centros_costo') ?: []`), reutilizar la validación de la Task 2 y, si es válida, reemplazar por completo el reparto existente de esa factura:

```php
$centrosCostoInput = $request->request->all('centros_costo') ?: [];
$centrosCostoFilas = [];
foreach ($centrosCostoInput as $fila) {
    $centroCostoId = (int) ($fila['centro_costo_id'] ?? 0);
    $porcentaje = (float) str_replace(',', '', (string) ($fila['porcentaje'] ?? 0));
    if ($centroCostoId <= 0) {
        continue;
    }
    $centrosCostoFilas[] = ['centro_costo_id' => $centroCostoId, 'porcentaje' => $porcentaje];
}

$erroresCentroCosto = $this->centroCostoValidator->validate($centrosCostoFilas);
if ($erroresCentroCosto !== []) {
    $this->addFlash('danger', 'Centros de costo: ' . implode(' ', $erroresCentroCosto));
    return $this->redirectToRoute('documento_lineas_edit', ['id' => $id]);
}

$existentes = $this->em->getRepository(\App\Entity\DocumentoCxpCentroCosto::class)->findBy(['documento' => $documento]);
foreach ($existentes as $existente) {
    $this->em->remove($existente);
}

if ($centrosCostoFilas !== []) {
    $centrosCostoCatalogo = $this->em->getRepository(\App\Entity\CentroCosto::class)
        ->createQueryBuilder('c')
        ->andWhere('c.id IN (:ids)')->setParameter('ids', array_column($centrosCostoFilas, 'centro_costo_id'))
        ->getQuery()->getResult();
    $centrosCostoPorId = [];
    foreach ($centrosCostoCatalogo as $centro) {
        $centrosCostoPorId[$centro->getId()] = $centro;
    }
    foreach ($centrosCostoFilas as $fila) {
        $centro = $centrosCostoPorId[$fila['centro_costo_id']] ?? null;
        if ($centro instanceof \App\Entity\CentroCosto) {
            $this->em->persist(new \App\Entity\DocumentoCxpCentroCosto($documento, $centro, number_format($fila['porcentaje'], 2, '.', '')));
        }
    }
}
```

(Nota para quien implemente: `$documento` es el nombre de variable que ya usa `updateLineas()` para la factura cargada — confirmar contra el archivo real. Insertar este bloque junto al resto de la lógica de guardado, dentro de la misma transacción/flush que ya usa el método.)

- [ ] **Step 2: Precargar el reparto existente en `edit_lineas.html.twig`**

Pasar el reparto actual desde `editLineas()` (GET) al template — un array `list<array{centroCostoId: int, porcentaje: string}>` obtenido de `DocumentoCxpCentroCosto` — y renderizar las filas iniciales server-side (a diferencia del wizard de creación, este template sí renderiza filas existentes en Twig directamente, siguiendo el patrón ya usado ahí para las líneas de detalle existentes). Reutilizar el mismo bloque HTML/JS de la Task 6 para agregar/quitar filas adicionales.

- [ ] **Step 3: Commit**

```bash
git add src/Controller/DocumentoController.php templates/documento/edit_lineas.html.twig
git commit -m "feat: permitir editar el reparto de centros de costo de una factura existente"
```

---

## Notas finales para quien ejecute este plan

- Las Tasks 5 y 8 tienen pasos marcados explícitamente como "revisar el archivo real antes de aplicar" porque se escribieron a partir de un resumen de exploración, no de una lectura línea por línea de `DocumentoController::store()` completo ni de `ChequeVoucherPrintDataBuilder::build()` completo. Es intencional: son los dos archivos más grandes/densos del cambio, y el plan documenta la intención y el código exacto a insertar, pero el ejecutor debe confirmar el punto de inserción exacto contra el archivo vigente en el momento de implementar.
- Ningún paso de este plan pudo ejecutarse (PHPUnit, migración, npm) en la sesión donde se escribió, por falta de `vendor/`/`node_modules/` en el entorno. Cada "Run" queda como instrucción para el entorno real del proyecto.
