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
referenceAutoproperty 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/finmb_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).