Files
Inventory/docs/superpowers/plans/2026-03-26-reference-auto.md
Matthieu 476060cf7d WIP
2026-03-31 17:57:59 +02:00

30 KiB

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

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
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:

#[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()
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
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)

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

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
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

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

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
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

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

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
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
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 kK, 2222, 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:

  • $referenceFormulareferenceformula
  • $requiredFieldsForReferencerequiredfieldsforreference
  • $referenceAutoreferenceauto

No explicit name attribute needed — this follows the existing pattern (typePieceIdtypepieceid, createdAtcreatedat).