# ReferenceAuto — Génération automatique de référence pièce

> **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:** Générer automatiquement une référence technique normalisée (`referenceAuto`) pour les pièces, basée sur une formule configurable définie au niveau du ModelType et alimentée par les CustomFieldValues de chaque Piece.

**Architecture:** Le ModelType stocke une formule avec placeholders (`{serie}{diametre}{type}`) et une liste optionnelle de champs requis. Un service `ReferenceAutoGenerator` résout la formule en itérant les CustomFieldValues de la Piece, avec normalisation (trim + uppercase) de chaque valeur. Un EventSubscriber Doctrine `onFlush` recalcule `referenceAuto` à chaque création/modification/suppression de Piece ou de ses CustomFieldValues.

**Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), API Platform, PostgreSQL, PHPUnit 12

---

## Règles métier

- **referenceAuto** est un champ système **non éditable** par l'utilisateur, distinct de `reference` (saisie libre)
- La formule produit un **code technique structuré**, pas du texte lisible (ex: `2207K`, `SNU507`, `U507`)
- Les valeurs des CustomFields sont **normalisées** avant assemblage : `trim()` + `mb_strtoupper()`
- Champ requis manquant ou vide → `referenceAuto = null`
- Pas de formule sur le ModelType → `referenceAuto = null`
- Pas de ModelType sur la Piece → `referenceAuto = null`
- Le recalcul est déclenché par : création/modification/suppression de Piece, création/modification/suppression de CustomFieldValue lié à une Piece
- L'absence de formule sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération
- Périmètre actuel : **Piece uniquement** (extensible à Composant/Product plus tard si besoin)

---

## File Structure

| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/Entity/ModelType.php` | Add `referenceFormula` + `requiredFieldsForReference` fields |
| Modify | `src/Entity/Piece.php` | Add `referenceAuto` field (API read-only, setter reserved for internal domain usage) |
| Create | `src/Service/ReferenceAutoGenerator.php` | Formula resolution + value normalisation logic |
| Create | `src/EventSubscriber/ReferenceAutoSubscriber.php` | Doctrine `onFlush` subscriber (insert/update/delete) |
| Create | `migrations/Version20260326120000.php` | Add DB columns |
| Create | `tests/Service/ReferenceAutoGeneratorTest.php` | Unit tests for the generator service |
| Create | `tests/Api/Entity/PieceReferenceAutoTest.php` | Integration tests via API |

---

### Task 1: Migration — Add database columns

**Files:**
- Create: `migrations/Version20260326120000.php`

- [ ] **Step 1: Create the migration file**

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260326120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
        $this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
        $this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
        $this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
        $this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
    }
}
```

- [ ] **Step 2: Run the migration**

Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully.

- [ ] **Step 3: Commit**

```bash
git add migrations/Version20260326120000.php
git commit -m "feat(reference-auto) : add migration for referenceAuto columns"
```

---

### Task 2: Entity — Add fields to ModelType

**Files:**
- Modify: `src/Entity/ModelType.php`

- [ ] **Step 1: Add properties after `$description` (around line 74)**

Add these fields to `ModelType.php`, after `$description` and before `$createdAt`:

```php
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?string $referenceFormula = null;

#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?array $requiredFieldsForReference = null;
```

Note: `referenceFormula` n'est PAS dans `piece:read` — c'est une donnée de configuration admin, pas nécessaire à l'affichage d'une pièce.

- [ ] **Step 2: Add getters and setters after `setDescription()`**

```php
public function getReferenceFormula(): ?string
{
    return $this->referenceFormula;
}

public function setReferenceFormula(?string $referenceFormula): static
{
    $this->referenceFormula = $referenceFormula;

    return $this;
}

public function getRequiredFieldsForReference(): ?array
{
    return $this->requiredFieldsForReference;
}

public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
{
    $this->requiredFieldsForReference = $requiredFieldsForReference;

    return $this;
}
```

- [ ] **Step 3: Run php-cs-fixer**

Run: `make php-cs-fixer-allow-risky`
Expected: All files fixed or already clean.

- [ ] **Step 4: Commit**

```bash
git add src/Entity/ModelType.php
git commit -m "feat(reference-auto) : add referenceFormula fields to ModelType entity"
```

---

### Task 3: Entity — Add `referenceAuto` to Piece

**Files:**
- Modify: `src/Entity/Piece.php`

- [ ] **Step 1: Add `referenceAuto` property after `$reference` (line 64)**

```php
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $referenceAuto = null;
```

- [ ] **Step 2: Add getter only (no public setter) after `setReference()`**

Le setter est `@internal` — seul le subscriber peut modifier ce champ. On n'expose pas de setter public pour protéger le contrat d'API. Le subscriber accède directement à la propriété via un setter interne.

```php
public function getReferenceAuto(): ?string
{
    return $this->referenceAuto;
}

/**
 * @internal Used by ReferenceAutoSubscriber only — not part of the public API.
 */
public function setReferenceAuto(?string $referenceAuto): static
{
    $this->referenceAuto = $referenceAuto;

    return $this;
}
```

- [ ] **Step 3: Run php-cs-fixer**

Run: `make php-cs-fixer-allow-risky`
Expected: Clean.

- [ ] **Step 4: Commit**

```bash
git add src/Entity/Piece.php
git commit -m "feat(reference-auto) : add referenceAuto field to Piece entity"
```

---

### Task 4: Service — ReferenceAutoGenerator

**Files:**
- Create: `src/Service/ReferenceAutoGenerator.php`
- Create: `tests/Service/ReferenceAutoGeneratorTest.php`

- [ ] **Step 1: Write the failing test**

Create `tests/Service/ReferenceAutoGeneratorTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Service;

use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;

/**
 * @internal
 */
class ReferenceAutoGeneratorTest extends AbstractApiTestCase
{
    public function testGenerateWithFormula(): void
    {
        $mt = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE);
        $mt->setReferenceFormula('{serie}{diametre}{type}');
        $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);

        $em = $this->getEntityManager();
        $em->flush();

        $cfSerie    = $this->createCustomField('serie', 'text', typePiece: $mt);
        $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
        $cfType     = $this->createCustomField('type', 'text', typePiece: $mt);

        $piece = $this->createPiece('Roulement Test', null, $mt);

        $this->createCustomFieldValue($cfSerie, '22', piece: $piece);
        $this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
        $this->createCustomFieldValue($cfType, 'K', piece: $piece);

        $em->refresh($piece);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertSame('2207K', $result);
    }

    public function testGenerateNormalizesValues(): void
    {
        $mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE);
        $mt->setReferenceFormula('{serie}{diametre}{type}');
        $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);

        $em = $this->getEntityManager();
        $em->flush();

        $cfSerie    = $this->createCustomField('serie', 'text', typePiece: $mt);
        $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
        $cfType     = $this->createCustomField('type', 'text', typePiece: $mt);

        $piece = $this->createPiece('Roulement Norm', null, $mt);

        // Values with spaces and lowercase — should be trimmed and uppercased
        $this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece);
        $this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
        $this->createCustomFieldValue($cfType, 'k', piece: $piece);

        $em->refresh($piece);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertSame('2207K', $result);
    }

    public function testGenerateReturnsNullWithoutFormula(): void
    {
        $mt    = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE);
        $piece = $this->createPiece('Galet Test', null, $mt);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertNull($result);
    }

    public function testGenerateReturnsNullWhenNoModelType(): void
    {
        $piece = $this->createPiece('Orphan Piece');

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertNull($result);
    }

    public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void
    {
        $mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE);
        $mt->setReferenceFormula('SNU {taille}');
        $mt->setRequiredFieldsForReference(['taille']);

        $em = $this->getEntityManager();
        $em->flush();

        $piece = $this->createPiece('Palier Test', null, $mt);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertNull($result);
    }

    public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void
    {
        $mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE);
        $mt->setReferenceFormula('SNU {taille}');
        $mt->setRequiredFieldsForReference(['taille']);

        $em = $this->getEntityManager();
        $em->flush();

        $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
        $piece    = $this->createPiece('Palier Vide', null, $mt);
        // Value is whitespace only — after trim, it's empty
        $this->createCustomFieldValue($cfTaille, '   ', piece: $piece);

        $em->refresh($piece);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertNull($result);
    }

    public function testGenerateWithStaticTextInFormula(): void
    {
        $mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
        $mt->setReferenceFormula('U{taille}');

        $em = $this->getEntityManager();
        $em->flush();

        $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
        $piece    = $this->createPiece('Joint Test', null, $mt);
        $this->createCustomFieldValue($cfTaille, '507', piece: $piece);

        $em->refresh($piece);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertSame('U507', $result);
    }

    public function testGenerateWithSpaceInFormula(): void
    {
        $mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
        $mt->setReferenceFormula('SNU {taille}');

        $em = $this->getEntityManager();
        $em->flush();

        $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
        $piece    = $this->createPiece('Palier Test 2', null, $mt);
        $this->createCustomFieldValue($cfTaille, '507', piece: $piece);

        $em->refresh($piece);

        $generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
        $result    = $generator->generate($piece);

        self::assertSame('SNU 507', $result);
    }
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: FAIL — class `App\Service\ReferenceAutoGenerator` not found.

- [ ] **Step 3: Create the service**

Create `src/Service/ReferenceAutoGenerator.php`:

The service contains all the resolution logic — no helper method needed on the Piece entity. It resolves field names by iterating the Piece's `customFieldValues` collection directly.

```php
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\CustomFieldValue;
use App\Entity\Piece;

class ReferenceAutoGenerator
{
    public function generate(Piece $piece): ?string
    {
        $modelType = $piece->getTypePiece();

        if (!$modelType || !$modelType->getReferenceFormula()) {
            return null;
        }

        $valueMap = $this->buildValueMap($piece);

        $requiredFields = $modelType->getRequiredFieldsForReference();

        if ($requiredFields) {
            foreach ($requiredFields as $fieldName) {
                if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) {
                    return null;
                }
            }
        }

        return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
            return $valueMap[$matches[1]] ?? '';
        }, $modelType->getReferenceFormula());
    }

    /**
     * Build a map of fieldName → normalized value from the Piece's CustomFieldValues.
     *
     * @return array<string, string>
     */
    private function buildValueMap(Piece $piece): array
    {
        $map = [];

        /** @var CustomFieldValue $cfv */
        foreach ($piece->getCustomFieldValues() as $cfv) {
            $normalized = mb_strtoupper(trim($cfv->getValue()));
            $map[$cfv->getCustomField()->getName()] = $normalized;
        }

        return $map;
    }
}
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: All 8 tests PASS.

- [ ] **Step 5: Run php-cs-fixer**

Run: `make php-cs-fixer-allow-risky`

- [ ] **Step 6: Commit**

```bash
git add src/Service/ReferenceAutoGenerator.php tests/Service/ReferenceAutoGeneratorTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoGenerator service with normalisation and tests"
```

---

### Task 5: EventSubscriber — Auto-recalculate on Piece and CustomFieldValue changes

**Files:**
- Create: `src/EventSubscriber/ReferenceAutoSubscriber.php`
- Create: `tests/Api/Entity/PieceReferenceAutoTest.php`

**Triggers for recalculation:**
- Piece inserted or updated
- CustomFieldValue inserted, updated, or **deleted** (linked to a Piece)

- [ ] **Step 1: Write the failing integration test**

Create `tests/Api/Entity/PieceReferenceAutoTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Api\Entity;

use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;

/**
 * @internal
 */
class PieceReferenceAutoTest extends AbstractApiTestCase
{
    public function testReferenceAutoGeneratedAfterAllCfvCreated(): void
    {
        $mt = $this->createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE);
        $mt->setReferenceFormula('{serie}{diametre}{type}');
        $mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);

        $em = $this->getEntityManager();
        $em->flush();

        $cfSerie    = $this->createCustomField('serie', 'text', typePiece: $mt);
        $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
        $cfType     = $this->createCustomField('type', 'text', typePiece: $mt);

        $piece = $this->createPiece('Roulement Auto', null, $mt);

        $this->createCustomFieldValue($cfSerie, '22', piece: $piece);
        $this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
        $this->createCustomFieldValue($cfType, 'K', piece: $piece);

        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['referenceAuto' => '2207K']);
    }

    public function testReferenceAutoNullWhenNoFormula(): void
    {
        $mt    = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE);
        $piece = $this->createPiece('Galet Auto', null, $mt);

        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['referenceAuto' => null]);
    }

    public function testReferenceAutoNullWhenRequiredFieldsMissing(): void
    {
        $mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE);
        $mt->setReferenceFormula('SNU {taille}');
        $mt->setRequiredFieldsForReference(['taille']);

        $em = $this->getEntityManager();
        $em->flush();

        $piece = $this->createPiece('Palier Sans Champ', null, $mt);

        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['referenceAuto' => null]);
    }

    public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void
    {
        $mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE);
        $mt->setReferenceFormula('U{taille}');

        $em = $this->getEntityManager();
        $em->flush();

        $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
        $piece    = $this->createPiece('Joint Upd', null, $mt);
        $cfv      = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);

        // After creating the CFV, the subscriber should have set referenceAuto
        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['referenceAuto' => 'U507']);

        // Now update the CFV value via API
        $gClient = $this->createGestionnaireClient();
        $gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['value' => '608'],
        ]);

        $this->assertResponseIsSuccessful();

        // Read piece again — referenceAuto should be updated
        $client->request('GET', self::iri('pieces', $piece->getId()));
        $this->assertJsonContains(['referenceAuto' => 'U608']);
    }

    public function testReferenceAutoNullAfterRequiredCfvDeleted(): void
    {
        $mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE);
        $mt->setReferenceFormula('U{taille}');
        $mt->setRequiredFieldsForReference(['taille']);

        $em = $this->getEntityManager();
        $em->flush();

        $cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
        $piece    = $this->createPiece('Joint Del', null, $mt);
        $cfv      = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);

        // Confirm referenceAuto is set
        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));
        $this->assertJsonContains(['referenceAuto' => 'U507']);

        // Delete the CFV
        $gClient = $this->createGestionnaireClient();
        $gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
        $this->assertResponseStatusCodeSame(204);

        // referenceAuto should now be null (required field missing)
        $client->request('GET', self::iri('pieces', $piece->getId()));
        $this->assertJsonContains(['referenceAuto' => null]);
    }

    public function testReferenceAutoIsReadOnlyViaApi(): void
    {
        $piece = $this->createPiece('ReadOnly Test');

        $client = $this->createGestionnaireClient();
        $client->request('PATCH', self::iri('pieces', $piece->getId()), [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'json'    => ['referenceAuto' => 'HACKED'],
        ]);

        $this->assertResponseIsSuccessful();

        $viewer = $this->createViewerClient();
        $viewer->request('GET', self::iri('pieces', $piece->getId()));
        // referenceAuto should still be null (no formula), not 'HACKED'
        $this->assertJsonContains(['referenceAuto' => null]);
    }

    public function testReferenceAutoNormalizesLowercaseValues(): void
    {
        $mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE);
        $mt->setReferenceFormula('{serie}{diametre}{type}');

        $em = $this->getEntityManager();
        $em->flush();

        $cfSerie    = $this->createCustomField('serie', 'text', typePiece: $mt);
        $cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
        $cfType     = $this->createCustomField('type', 'text', typePiece: $mt);

        $piece = $this->createPiece('Roulement Norm', null, $mt);

        $this->createCustomFieldValue($cfSerie, '22', piece: $piece);
        $this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
        $this->createCustomFieldValue($cfType, 'k', piece: $piece);

        $client = $this->createViewerClient();
        $client->request('GET', self::iri('pieces', $piece->getId()));

        $this->assertResponseIsSuccessful();
        // 'k' should be normalized to 'K'
        $this->assertJsonContains(['referenceAuto' => '2207K']);
    }
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: FAIL — referenceAuto not being set automatically.

- [ ] **Step 3: Create the EventSubscriber**

Create `src/EventSubscriber/ReferenceAutoSubscriber.php`:

```php
<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Service\ReferenceAutoGenerator;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;

final class ReferenceAutoSubscriber implements EventSubscriber
{
    public function __construct(private readonly ReferenceAutoGenerator $generator) {}

    public function getSubscribedEvents(): array
    {
        return [Events::onFlush];
    }

    public function onFlush(OnFlushEventArgs $args): void
    {
        $em  = $args->getObjectManager();
        $uow = $em->getUnitOfWork();

        $piecesToRecalculate = [];

        // Collect Pieces from direct insertions/updates
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof Piece) {
                $piecesToRecalculate[$entity->getId()] = $entity;
            }
        }

        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if ($entity instanceof Piece) {
                $piecesToRecalculate[$entity->getId()] = $entity;
            }
        }

        // Collect Pieces from CustomFieldValue insertions
        // The new CFV is not yet in the DB, so Piece's lazy-loaded collection won't
        // contain it. We must add it manually so the generator sees the new value.
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
                $piece = $entity->getPiece();
                if (!$piece->getCustomFieldValues()->contains($entity)) {
                    $piece->getCustomFieldValues()->add($entity);
                }
                $piecesToRecalculate[$piece->getId()] = $piece;
            }
        }

        // Collect Pieces from CustomFieldValue updates
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
                $piece = $entity->getPiece();
                $piecesToRecalculate[$piece->getId()] = $piece;
            }
        }

        // Collect Pieces from CustomFieldValue deletions
        // When a CFV is deleted, remove it from the collection so the generator
        // doesn't see the stale value. referenceAuto must revert to null if required.
        foreach ($uow->getScheduledEntityDeletions() as $entity) {
            if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
                $piece = $entity->getPiece();
                $piece->getCustomFieldValues()->removeElement($entity);
                $piecesToRecalculate[$piece->getId()] = $piece;
            }
        }

        // Recalculate referenceAuto for each collected Piece
        $meta = $em->getClassMetadata(Piece::class);

        foreach ($piecesToRecalculate as $piece) {
            $newRef = $this->generator->generate($piece);

            if ($piece->getReferenceAuto() !== $newRef) {
                $piece->setReferenceAuto($newRef);
                $uow->recomputeSingleEntityChangeSet($meta, $piece);
            }
        }
    }
}
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: All 7 tests PASS.

- [ ] **Step 5: Run php-cs-fixer**

Run: `make php-cs-fixer-allow-risky`

- [ ] **Step 6: Commit**

```bash
git add src/EventSubscriber/ReferenceAutoSubscriber.php tests/Api/Entity/PieceReferenceAutoTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoSubscriber with insert/update/delete handling"
```

---

### Task 6: Run full test suite and final cleanup

**Files:**
- All modified files

- [ ] **Step 1: Run php-cs-fixer on all modified files**

Run: `make php-cs-fixer-allow-risky`
Expected: Clean.

- [ ] **Step 2: Run the full test suite**

Run: `make test`
Expected: All tests PASS, including existing tests that were not modified.

- [ ] **Step 3: Verify the migration applies cleanly on test DB**

Run: `make test-setup`
Expected: Schema up to date.

- [ ] **Step 4: Final commit if any cleanup was needed**

```bash
git add -A
git commit -m "chore(reference-auto) : final cleanup and lint fixes"
```

---

## Design Notes

### Formule = code technique, pas texte libre

La formule doit produire un **code technique structuré** (ex: `2207K`, `SNU507`), pas une description lisible. Exemples valides : `{serie}{diametre}{type}`, `U{taille}`, `SNU {taille}`. Exemples à éviter : `Roulement série {serie} diamètre {diametre}`.

### Normalisation des valeurs

Chaque valeur de CustomField est normalisée avant insertion dans la formule :
- `trim()` — supprime les espaces en début/fin
- `mb_strtoupper()` — convertit en majuscules

Cela garantit que `k` → `K`, ` 22 ` → `22`, etc. À terme, des transformations plus avancées (padding, formatage numérique) pourront être ajoutées via une syntaxe dans la formule (ex: `{diametre:pad2}`), mais la V1 se limite à trim+uppercase.

### Why `onFlush` instead of `prePersist`/`preUpdate`?

`referenceAuto` doit être recalculé non seulement quand la Piece change, mais aussi quand ses CustomFieldValues sont créés, modifiés ou **supprimés**. `onFlush` intercepte tous ces cas en un seul subscriber. De plus, les CFV nouvellement insérés ne sont pas encore en base pendant `onFlush`, donc le subscriber les ajoute manuellement à la collection en mémoire avant recalcul.

### Why no `getCustomFieldValueByName()` on Piece?

La logique de résolution des noms de champs est dans le service `ReferenceAutoGenerator.buildValueMap()`, pas dans l'entité. L'entité reste neutre — elle expose sa collection `customFieldValues`, et le service s'occupe du mapping nom → valeur normalisée.

### Read-only via API

Le setter `setReferenceAuto()` est marqué `@internal`. Le subscriber écrase toute valeur sur chaque flush. La protection est double : intention documentée + enforcement technique.

### Éligibilité implicite

L'absence de `referenceFormula` sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération automatique. Pas besoin d'un flag booléen séparé.

### Extensibilité future

Le périmètre actuel est **Piece uniquement**. Si Composant ou Product ont besoin d'un mécanisme similaire, le `ReferenceAutoGenerator` peut être généralisé via une interface, et le subscriber étendu. Mais YAGNI — on n'implémente que ce qui est nécessaire maintenant.

### Limitation V1 : recalcul sur changement de formule ModelType

Si un admin modifie la `referenceFormula` d'un ModelType, les `referenceAuto` des pièces existantes ne sont **pas** recalculées automatiquement. Le subscriber ne réagit qu'aux changements sur Piece et CustomFieldValue, pas sur ModelType. Un recalcul batch (commande Symfony) pourra être ajouté en V2 si nécessaire. C'est un compromis V1 accepté volontairement.

### Column name mapping

PostgreSQL column names are always lowercase. Doctrine uses the PHP property name as column name, which PG lowercases:
- `$referenceFormula` → `referenceformula`
- `$requiredFieldsForReference` → `requiredfieldsforreference`
- `$referenceAuto` → `referenceauto`

No explicit `name` attribute needed — this follows the existing pattern (`typePieceId` → `typepieceid`, `createdAt` → `createdat`).
