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

36 KiB
Raw Permalink Blame History

Supplier References 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: Allow storing a supplier reference (supplierReference) per (item, constructeur) pair by converting ManyToMany join tables to proper Link entities.

Architecture: Replace the 4 simple join tables (_MachineConstructeurs, _PieceConstructeurs, _ComposantConstructeurs, _ProductConstructeurs) with Doctrine entities following the existing *Link pattern. Each entity holds supplierReference (string, nullable). Update all consumers: audit subscribers, version service, MCP tools, structure controller, category conversion service.

Tech Stack: Symfony 8 / Doctrine ORM / API Platform / PHP 8.4 / PostgreSQL


File Structure

New files

  • src/Entity/MachineConstructeurLink.php — pivot entity machine↔constructeur
  • src/Entity/PieceConstructeurLink.php — pivot entity piece↔constructeur
  • src/Entity/ComposantConstructeurLink.php — pivot entity composant↔constructeur
  • src/Entity/ProductConstructeurLink.php — pivot entity product↔constructeur
  • src/Repository/MachineConstructeurLinkRepository.php
  • src/Repository/PieceConstructeurLinkRepository.php
  • src/Repository/ComposantConstructeurLinkRepository.php
  • src/Repository/ProductConstructeurLinkRepository.php
  • migrations/VersionXXX.php — migration (auto-generated, then adjusted)

Modified files

  • src/Entity/Machine.php — replace ManyToMany with OneToMany to Link
  • src/Entity/Piece.php — replace ManyToMany with OneToMany to Link
  • src/Entity/Composant.php — replace ManyToMany with OneToMany to Link
  • src/Entity/Product.php — replace ManyToMany with OneToMany to Link
  • src/Entity/Constructeur.php — replace 4 ManyToMany with 4 OneToMany to Links
  • src/Controller/MachineStructureController.php — update clone + normalize
  • src/Service/EntityVersionService.php — update snapshot/restore/diff for constructeur links
  • src/Service/ModelTypeCategoryConversionService.php — update table names in raw SQL
  • src/EventSubscriber/AbstractAuditSubscriber.php — remove ManyToMany collection tracking for constructeurs
  • src/EventSubscriber/MachineAuditSubscriber.php — update snapshotEntity
  • src/EventSubscriber/PieceAuditSubscriber.php — update snapshotEntity
  • src/EventSubscriber/ComposantAuditSubscriber.php — update snapshotEntity
  • src/EventSubscriber/ProductAuditSubscriber.php — update snapshotEntity
  • src/Mcp/Tool/Machine/CreateMachineTool.php — use Links instead of addConstructeur
  • src/Mcp/Tool/Machine/UpdateMachineTool.php — use Links
  • src/Mcp/Tool/Machine/GetMachineTool.php — read from Links
  • src/Mcp/Tool/Piece/CreatePieceTool.php — use Links
  • src/Mcp/Tool/Piece/UpdatePieceTool.php — use Links
  • src/Mcp/Tool/Piece/GetPieceTool.php — read from Links
  • src/Mcp/Tool/Composant/CreateComposantTool.php — use Links
  • src/Mcp/Tool/Composant/UpdateComposantTool.php — use Links
  • src/Mcp/Tool/Composant/GetComposantTool.php — read from Links
  • src/Mcp/Tool/Product/CreateProductTool.php — use Links
  • src/Mcp/Tool/Product/UpdateProductTool.php — use Links
  • src/Mcp/Tool/Product/GetProductTool.php — read from Links
  • tests/AbstractApiTestCase.php — add createConstructeurLink() factory helpers

Files:

  • Create: src/Entity/MachineConstructeurLink.php

  • Create: src/Entity/PieceConstructeurLink.php

  • Create: src/Entity/ComposantConstructeurLink.php

  • Create: src/Entity/ProductConstructeurLink.php

  • Create: src/Repository/MachineConstructeurLinkRepository.php

  • Create: src/Repository/PieceConstructeurLinkRepository.php

  • Create: src/Repository/ComposantConstructeurLinkRepository.php

  • Create: src/Repository/ProductConstructeurLinkRepository.php

  • Step 1: Create MachineConstructeurLink entity

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineConstructeurLinkRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: MachineConstructeurLinkRepository::class)]
#[ORM\Table(name: 'machine_constructeur_links')]
#[ORM\UniqueConstraint(name: 'uniq_machine_constructeur', columns: ['machineid', 'constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
    description: 'Liaisons machinefournisseur. Chaque liaison peut porter une référence fournisseur spécifique.',
    operations: [
        new Get(security: "is_granted('ROLE_VIEWER')"),
        new GetCollection(security: "is_granted('ROLE_VIEWER')"),
        new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
        new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
        new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
        new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
    ]
)]
class MachineConstructeurLink
{
    use CuidEntityTrait;

    #[ORM\Id]
    #[ORM\Column(type: Types::STRING, length: 36)]
    private ?string $id = null;

    #[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'constructeurLinks')]
    #[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private Machine $machine;

    #[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'machineLinks')]
    #[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private Constructeur $constructeur;

    #[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'supplierReference')]
    private ?string $supplierReference = null;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
    private DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
    private DateTimeImmutable $updatedAt;

    public function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
        $this->updatedAt = new DateTimeImmutable();
    }

    public function getMachine(): Machine
    {
        return $this->machine;
    }

    public function setMachine(Machine $machine): static
    {
        $this->machine = $machine;

        return $this;
    }

    public function getConstructeur(): Constructeur
    {
        return $this->constructeur;
    }

    public function setConstructeur(Constructeur $constructeur): static
    {
        $this->constructeur = $constructeur;

        return $this;
    }

    public function getSupplierReference(): ?string
    {
        return $this->supplierReference;
    }

    public function setSupplierReference(?string $supplierReference): static
    {
        $this->supplierReference = $supplierReference;

        return $this;
    }
}
  • Step 2: Create PieceConstructeurLink entity

Same structure as MachineConstructeurLink, replacing:

  • Class name: PieceConstructeurLink

  • Repository: PieceConstructeurLinkRepository

  • Table: piece_constructeur_links

  • Unique constraint: uniq_piece_constructeur on ['pieceid', 'constructeurid']

  • ManyToOne: Piece (inversedBy: constructeurLinks, joinColumn: pieceId)

  • Constructeur inversedBy: pieceLinks

  • Description: 'Liaisons piècefournisseur. Chaque liaison peut porter une référence fournisseur spécifique.'

  • Property + getter/setter: piece / getPiece() / setPiece(Piece $piece)

  • Step 3: Create ComposantConstructeurLink entity

Same structure, replacing:

  • Class name: ComposantConstructeurLink

  • Repository: ComposantConstructeurLinkRepository

  • Table: composant_constructeur_links

  • Unique constraint: uniq_composant_constructeur on ['composantid', 'constructeurid']

  • ManyToOne: Composant (inversedBy: constructeurLinks, joinColumn: composantId)

  • Constructeur inversedBy: composantLinks

  • Description: 'Liaisons composantfournisseur. Chaque liaison peut porter une référence fournisseur spécifique.'

  • Property + getter/setter: composant / getComposant() / setComposant(Composant $composant)

  • Step 4: Create ProductConstructeurLink entity

Same structure, replacing:

  • Class name: ProductConstructeurLink

  • Repository: ProductConstructeurLinkRepository

  • Table: product_constructeur_links

  • Unique constraint: uniq_product_constructeur on ['productid', 'constructeurid']

  • ManyToOne: Product (inversedBy: constructeurLinks, joinColumn: productId)

  • Constructeur inversedBy: productLinks

  • Description: 'Liaisons produitfournisseur. Chaque liaison peut porter une référence fournisseur spécifique.'

  • Property + getter/setter: product / getProduct() / setProduct(Product $product)

  • Step 5: Create the 4 repositories

Each repository follows the MachinePieceLinkRepository pattern:

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\MachineConstructeurLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<MachineConstructeurLink>
 */
class MachineConstructeurLinkRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, MachineConstructeurLink::class);
    }
}

Repeat for PieceConstructeurLinkRepository, ComposantConstructeurLinkRepository, ProductConstructeurLinkRepository (changing entity class).

  • Step 6: Run php-cs-fixer

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

  • Step 7: Commit
git add src/Entity/*ConstructeurLink.php src/Repository/*ConstructeurLinkRepository.php
git commit -m "feat(constructeur) : add 4 ConstructeurLink pivot entities with supplierReference"

Task 2: Update existing entities (Machine, Piece, Composant, Product, Constructeur)

Files:

  • Modify: src/Entity/Machine.php

  • Modify: src/Entity/Piece.php

  • Modify: src/Entity/Composant.php

  • Modify: src/Entity/Product.php

  • Modify: src/Entity/Constructeur.php

  • Step 1: Update Machine.php

Replace the ManyToMany property and methods with OneToMany:

Remove:

/**
 * @var Collection<int, Constructeur>
 */
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')]
#[ORM\JoinTable(
    name: '_MachineConstructeurs',
    joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
    inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
private Collection $constructeurs;

Add:

/**
 * @var Collection<int, MachineConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineConstructeurLink::class, cascade: ['remove'])]
private Collection $constructeurLinks;

In __construct(), replace $this->constructeurs = new ArrayCollection(); with $this->constructeurLinks = new ArrayCollection();

Replace methods getConstructeurs(), addConstructeur(), removeConstructeur() with:

/**
 * @return Collection<int, MachineConstructeurLink>
 */
public function getConstructeurLinks(): Collection
{
    return $this->constructeurLinks;
}
  • Step 2: Update Piece.php

Same pattern as Machine. Replace ManyToMany constructeurs property (with #[Groups(['piece:read'])]) with:

/**
 * @var Collection<int, PieceConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: PieceConstructeurLink::class, cascade: ['remove'])]
#[Groups(['piece:read'])]
private Collection $constructeurLinks;

In __construct(): $this->constructeurLinks = new ArrayCollection();

Replace getter/add/remove with getConstructeurLinks(): Collection.

  • Step 3: Update Composant.php

Same pattern. Replace ManyToMany (with #[Groups(['composant:read'])]) with:

/**
 * @var Collection<int, ComposantConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: ComposantConstructeurLink::class, cascade: ['remove'])]
#[Groups(['composant:read'])]
private Collection $constructeurLinks;
  • Step 4: Update Product.php

Same pattern. Replace ManyToMany (with #[Groups(['product:read'])]) with:

/**
 * @var Collection<int, ProductConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'product', targetEntity: ProductConstructeurLink::class, cascade: ['remove'])]
#[Groups(['product:read'])]
private Collection $constructeurLinks;
  • Step 5: Update Constructeur.php

Replace the 4 ManyToMany properties and their initializations in __construct():

Remove all 4 ManyToMany properties (machines, composants, pieces, products) and replace with:

/**
 * @var Collection<int, MachineConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: MachineConstructeurLink::class, cascade: ['remove'])]
private Collection $machineLinks;

/**
 * @var Collection<int, ComposantConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ComposantConstructeurLink::class, cascade: ['remove'])]
private Collection $composantLinks;

/**
 * @var Collection<int, PieceConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: PieceConstructeurLink::class, cascade: ['remove'])]
private Collection $pieceLinks;

/**
 * @var Collection<int, ProductConstructeurLink>
 */
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ProductConstructeurLink::class, cascade: ['remove'])]
private Collection $productLinks;

In __construct(), replace the 4 ArrayCollection inits with:

$this->machineLinks   = new ArrayCollection();
$this->composantLinks = new ArrayCollection();
$this->pieceLinks     = new ArrayCollection();
$this->productLinks   = new ArrayCollection();
  • Step 6: Run php-cs-fixer

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

  • Step 7: Commit
git add src/Entity/Machine.php src/Entity/Piece.php src/Entity/Composant.php src/Entity/Product.php src/Entity/Constructeur.php
git commit -m "refactor(constructeur) : replace ManyToMany with OneToMany to ConstructeurLink entities"

Task 3: Generate and adjust migration

Files:

  • Create: migrations/VersionXXX.php (auto-generated)

  • Step 1: Generate migration diff

Run inside docker:

docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
  • Step 2: Edit the generated migration

The auto-generated migration will try to drop the old tables and create new ones. We need to add data migration in between. Edit the migration up() method to follow this order:

  1. Create the 4 new tables (keep the auto-generated CREATE TABLE statements)
  2. Add data migration — insert from old join tables into new Link tables:
-- Machine constructeur links
INSERT INTO machine_constructeur_links (id, machineid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), a, b, NULL, NOW(), NOW()
FROM "_machineconstructeurs";

-- Piece constructeur links
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), a, b, NULL, NOW(), NOW()
FROM "_piececonstructeurs";

-- Composant constructeur links
INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), a, b, NULL, NOW(), NOW()
FROM "_composantconstructeurs";

-- Product constructeur links
INSERT INTO product_constructeur_links (id, productid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || encode(gen_random_bytes(12), 'hex'), a, b, NULL, NOW(), NOW()
FROM "_productconstructeurs";
  1. Drop the 4 old join tables (keep the auto-generated DROP TABLE statements)

For down(): reverse — recreate old tables, migrate data back (without supplierReference), drop new tables.

  • Step 3: Run the migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate

Expected: migration passes without errors.

  • Step 4: Verify data
docker exec -u www-data php-inventory-apache php bin/console doctrine:query:sql "SELECT COUNT(*) FROM machine_constructeur_links"
docker exec -u www-data php-inventory-apache php bin/console doctrine:query:sql "SELECT COUNT(*) FROM piece_constructeur_links"
docker exec -u www-data php-inventory-apache php bin/console doctrine:query:sql "SELECT COUNT(*) FROM composant_constructeur_links"
docker exec -u www-data php-inventory-apache php bin/console doctrine:query:sql "SELECT COUNT(*) FROM product_constructeur_links"

Expected: counts match the old join tables.

  • Step 5: Commit
git add migrations/
git commit -m "feat(constructeur) : add migration for ConstructeurLink tables with data migration"

Task 4: Update MachineStructureController

Files:

  • Modify: src/Controller/MachineStructureController.php:141-144 (clone)

  • Modify: src/Controller/MachineStructureController.php:589 (normalize in structure response)

  • Modify: src/Controller/MachineStructureController.php:814-824 (normalizeConstructeurs)

  • Step 1: Update clone logic (lines ~141-144)

Replace:

// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
    $newMachine->getConstructeurs()->add($constructeur);
}

With:

// Copy constructeur links
foreach ($source->getConstructeurLinks() as $link) {
    $newLink = new MachineConstructeurLink();
    $newLink->setMachine($newMachine);
    $newLink->setConstructeur($link->getConstructeur());
    $newLink->setSupplierReference($link->getSupplierReference());
    $this->entityManager->persist($newLink);
}

Add use App\Entity\MachineConstructeurLink; at top of file.

  • Step 2: Update structure response (line ~589)

Replace:

'constructeurs'     => $this->normalizeConstructeurs($machine->getConstructeurs()),

With:

'constructeurs'     => $this->normalizeConstructeurLinks($machine->getConstructeurLinks()),
  • Step 3: Update normalizeConstructeurs → normalizeConstructeurLinks (lines ~814-824)

Replace the method:

private function normalizeConstructeurs(Collection $constructeurs): array
{
    $items = [];
    foreach ($constructeurs as $constructeur) {
        $items[] = [
            'id'    => $constructeur->getId(),
            'name'  => $constructeur->getName(),
            'email' => $constructeur->getEmail(),
            'phone' => $constructeur->getPhone(),
        ];
    }

    return $items;
}

With:

private function normalizeConstructeurLinks(Collection $constructeurLinks): array
{
    $items = [];
    foreach ($constructeurLinks as $link) {
        $items[] = [
            'id'                => $link->getId(),
            'constructeur'      => [
                'id'    => $link->getConstructeur()->getId(),
                'name'  => $link->getConstructeur()->getName(),
                'email' => $link->getConstructeur()->getEmail(),
                'phone' => $link->getConstructeur()->getPhone(),
            ],
            'supplierReference' => $link->getSupplierReference(),
        ];
    }

    return $items;
}
  • Step 4: Run php-cs-fixer

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

  • Step 5: Commit
git add src/Controller/MachineStructureController.php
git commit -m "refactor(machines) : update structure controller to use ConstructeurLinks"

Task 5: Update audit subscribers

Files:

  • Modify: src/EventSubscriber/AbstractAuditSubscriber.php:437-458

  • Modify: src/EventSubscriber/MachineAuditSubscriber.php:107

  • Modify: src/EventSubscriber/PieceAuditSubscriber.php:66

  • Modify: src/EventSubscriber/ComposantAuditSubscriber.php:89

  • Modify: src/EventSubscriber/ProductAuditSubscriber.php:53

  • Step 1: Update AbstractAuditSubscriber.php

The ManyToMany collection tracking for constructeurs is no longer needed since constructeur links are now separate entities tracked by their own lifecycle.

In collectCollectionUpdate() (line ~438), the check for 'constructeurs' !== $fieldName can be removed or the method can be simplified. Since constructeurLinks is a OneToMany (inverse side), Doctrine won't fire collection update events for it — the Link entities themselves are tracked as inserts/updates/deletes.

If hasCollectionTracking() was ONLY used for constructeurs, set it to return false in all subscribers and remove the collectCollectionUpdate method body. If other collections are tracked, keep the infrastructure but remove the constructeurs-specific logic.

Check: search for any other fieldName checks in collectCollectionUpdate. If 'constructeurs' is the only one, simplify by removing the collection tracking entirely.

  • Step 2: Update snapshot methods in all 4 audit subscribers

Replace 'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()) with:

'constructeurIds' => array_map(
    fn ($link) => [
        'id'                => $link->getConstructeur()->getId(),
        'name'              => $link->getConstructeur()->getName(),
        'supplierReference' => $link->getSupplierReference(),
    ],
    $entity->getConstructeurLinks()->toArray(),
),

Apply this change in:

  • MachineAuditSubscriber.php (line ~107)

  • PieceAuditSubscriber.php (line ~66)

  • ComposantAuditSubscriber.php (line ~89)

  • ProductAuditSubscriber.php (line ~53)

  • Step 3: Run php-cs-fixer

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

  • Step 4: Commit
git add src/EventSubscriber/
git commit -m "refactor(audit) : update audit subscribers to use ConstructeurLinks"

Task 6: Update EntityVersionService

Files:

  • Modify: src/Service/EntityVersionService.php:255-281 (checkIntegrity)

  • Modify: src/Service/EntityVersionService.php:395-410 (buildRestoreDiff)

  • Modify: src/Service/EntityVersionService.php:544-575 (restoreConstructeurs)

  • Step 1: Update checkIntegrity() (lines ~255-281)

No structural change needed — the integrity check already works with constructeurIds from the snapshot and checks if the Constructeur entities still exist. The format is compatible.

  • Step 2: Update buildRestoreDiff() (lines ~395-410)

Replace:

$currentConstructeurIds = [];
if (method_exists($entity, 'getConstructeurs')) {
    foreach ($entity->getConstructeurs() as $c) {
        $currentConstructeurIds[] = $c->getId();
    }
}

With:

$currentConstructeurIds = [];
if (method_exists($entity, 'getConstructeurLinks')) {
    foreach ($entity->getConstructeurLinks() as $link) {
        $currentConstructeurIds[] = $link->getConstructeur()->getId();
    }
}
  • Step 3: Update restoreConstructeurs() (lines ~544-575)

Replace the entire method. Instead of add/remove on a ManyToMany collection, we now create/delete Link entities:

private function restoreConstructeurs(object $entity, array $snapshot): void
{
    if (!method_exists($entity, 'getConstructeurLinks')) {
        return;
    }

    $targetIds = [];
    $targetRefs = [];
    foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
        $id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
        if ($id) {
            $targetIds[] = $id;
            $targetRefs[$id] = is_array($entry) ? ($entry['supplierReference'] ?? null) : null;
        }
    }

    // Remove current links not in snapshot
    foreach ($entity->getConstructeurLinks()->toArray() as $link) {
        $cId = $link->getConstructeur()->getId();
        if (!in_array($cId, $targetIds, true)) {
            $this->em->remove($link);
        } else {
            // Update supplierReference if present in snapshot
            if (isset($targetRefs[$cId])) {
                $link->setSupplierReference($targetRefs[$cId]);
            }
        }
    }

    // Add missing constructeur links from snapshot
    $currentIds = array_map(
        fn ($link) => $link->getConstructeur()->getId(),
        $entity->getConstructeurLinks()->toArray(),
    );
    foreach ($targetIds as $id) {
        if (!in_array($id, $currentIds, true)) {
            $constructeur = $this->constructeurs->find($id);
            if (null !== $constructeur) {
                $linkClass = $this->getConstructeurLinkClass($entity);
                $link = new $linkClass();
                $link->{'set' . $this->getEntityShortName($entity)}($entity);
                $link->setConstructeur($constructeur);
                $link->setSupplierReference($targetRefs[$id] ?? null);
                $this->em->persist($link);
            }
        }
    }
}

private function getConstructeurLinkClass(object $entity): string
{
    return match (true) {
        $entity instanceof Machine   => MachineConstructeurLink::class,
        $entity instanceof Piece     => PieceConstructeurLink::class,
        $entity instanceof Composant => ComposantConstructeurLink::class,
        $entity instanceof Product   => ProductConstructeurLink::class,
        default                      => throw new \LogicException('Unsupported entity type'),
    };
}

private function getEntityShortName(object $entity): string
{
    return match (true) {
        $entity instanceof Machine   => 'Machine',
        $entity instanceof Piece     => 'Piece',
        $entity instanceof Composant => 'Composant',
        $entity instanceof Product   => 'Product',
        default                      => throw new \LogicException('Unsupported entity type'),
    };
}

Add the necessary use statements at the top.

  • Step 4: Run php-cs-fixer

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

  • Step 5: Commit
git add src/Service/EntityVersionService.php
git commit -m "refactor(versioning) : update EntityVersionService to use ConstructeurLinks"

Task 7: Update ModelTypeCategoryConversionService

Files:

  • Modify: src/Service/ModelTypeCategoryConversionService.php:287-299 (piece→composant)

  • Modify: src/Service/ModelTypeCategoryConversionService.php:355-367 (composant→piece)

  • Step 1: Update piece→composant conversion (lines ~287-299)

Replace the raw SQL that references _piececonstructeurs and _composantconstructeurs:

// 2. Transfer constructeur link associations
$this->connection->executeStatement(
    'INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
     SELECT \'cl\' || encode(gen_random_bytes(12), \'hex\'), pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, pcl.updatedat
     FROM piece_constructeur_links pcl
     WHERE pcl.pieceid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
    ['id' => $modelTypeId],
);

$this->connection->executeStatement(
    'DELETE FROM piece_constructeur_links
     WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
    ['id' => $modelTypeId],
);
  • Step 2: Update composant→piece conversion (lines ~355-367)
// 2. Transfer constructeur link associations
$this->connection->executeStatement(
    'INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
     SELECT \'cl\' || encode(gen_random_bytes(12), \'hex\'), ccl.composantid, ccl.constructeurid, ccl.supplierreference, ccl.createdat, ccl.updatedat
     FROM composant_constructeur_links ccl
     WHERE ccl.composantid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
    ['id' => $modelTypeId],
);

$this->connection->executeStatement(
    'DELETE FROM composant_constructeur_links
     WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
    ['id' => $modelTypeId],
);
  • Step 3: Run php-cs-fixer

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

  • Step 4: Commit
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "refactor(conversion) : update category conversion to use ConstructeurLink tables"

Task 8: Update MCP Tools

Files:

  • Modify: src/Mcp/Tool/Machine/CreateMachineTool.php

  • Modify: src/Mcp/Tool/Machine/UpdateMachineTool.php

  • Modify: src/Mcp/Tool/Machine/GetMachineTool.php

  • Modify: src/Mcp/Tool/Piece/CreatePieceTool.php

  • Modify: src/Mcp/Tool/Piece/UpdatePieceTool.php

  • Modify: src/Mcp/Tool/Piece/GetPieceTool.php

  • Modify: src/Mcp/Tool/Composant/CreateComposantTool.php

  • Modify: src/Mcp/Tool/Composant/UpdateComposantTool.php

  • Modify: src/Mcp/Tool/Composant/GetComposantTool.php

  • Modify: src/Mcp/Tool/Product/CreateProductTool.php

  • Modify: src/Mcp/Tool/Product/UpdateProductTool.php

  • Modify: src/Mcp/Tool/Product/GetProductTool.php

  • Step 1: Update CreateMachineTool.php

Replace the constructeur-adding loop (lines ~59-65):

foreach ($constructeurIds as $cId) {
    $c = $this->constructeurs->find($cId);
    if (!$c) {
        $this->mcpError('not_found', "Constructeur not found: {$cId}");
    }
    $machine->addConstructeur($c);
}

With:

foreach ($constructeurIds as $cId) {
    $c = $this->constructeurs->find($cId);
    if (!$c) {
        $this->mcpError('not_found', "Constructeur not found: {$cId}");
    }
    $link = new MachineConstructeurLink();
    $link->setMachine($machine);
    $link->setConstructeur($c);
    $this->em->persist($link);
}

Add use App\Entity\MachineConstructeurLink;.

  • Step 2: Update UpdateMachineTool.php

Replace (lines ~69-80):

if (null !== $constructeurIds) {
    foreach ($machine->getConstructeurs()->toArray() as $existing) {
        $machine->removeConstructeur($existing);
    }
    foreach ($constructeurIds as $cId) {
        $c = $this->constructeurs->find($cId);
        if (!$c) {
            $this->mcpError('not_found', "Constructeur not found: {$cId}");
        }
        $machine->addConstructeur($c);
    }
}

With:

if (null !== $constructeurIds) {
    foreach ($machine->getConstructeurLinks()->toArray() as $existing) {
        $this->em->remove($existing);
    }
    foreach ($constructeurIds as $cId) {
        $c = $this->constructeurs->find($cId);
        if (!$c) {
            $this->mcpError('not_found', "Constructeur not found: {$cId}");
        }
        $link = new MachineConstructeurLink();
        $link->setMachine($machine);
        $link->setConstructeur($c);
        $this->em->persist($link);
    }
}

Add use App\Entity\MachineConstructeurLink;.

  • Step 3: Update GetMachineTool.php

Replace (lines ~32-38):

$constructeurs = [];
foreach ($machine->getConstructeurs() as $c) {
    $constructeurs[] = [
        'id'   => $c->getId(),
        'name' => $c->getName(),
    ];
}

With:

$constructeurs = [];
foreach ($machine->getConstructeurLinks() as $link) {
    $constructeurs[] = [
        'id'                => $link->getConstructeur()->getId(),
        'name'              => $link->getConstructeur()->getName(),
        'supplierReference' => $link->getSupplierReference(),
    ];
}
  • Step 4: Repeat for Piece tools

Apply the same patterns to CreatePieceTool.php, UpdatePieceTool.php, GetPieceTool.php — replacing Machine references with Piece and using PieceConstructeurLink.

  • Step 5: Repeat for Composant tools

Apply to CreateComposantTool.php, UpdateComposantTool.php, GetComposantTool.php — using ComposantConstructeurLink.

  • Step 6: Repeat for Product tools

Apply to CreateProductTool.php, UpdateProductTool.php, GetProductTool.php — using ProductConstructeurLink.

  • Step 7: Run php-cs-fixer

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

  • Step 8: Commit
git add src/Mcp/Tool/
git commit -m "refactor(mcp) : update MCP tools to use ConstructeurLinks"

Task 9: Update test helpers + fix existing tests

Files:

  • Modify: tests/AbstractApiTestCase.php

  • Modify: tests/Mcp/Tool/Machine/MachinesCrudToolTest.php

  • Modify: tests/Mcp/Tool/Piece/PiecesCrudToolTest.php

  • Modify: tests/Mcp/Tool/Composant/ComposantsCrudToolTest.php

  • Modify: tests/Mcp/Tool/Product/ProductsCrudToolTest.php

  • Step 1: Add factory helpers in AbstractApiTestCase.php

Add after the existing createConstructeur() method:

protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{
    $link = new MachineConstructeurLink();
    $link->setMachine($machine);
    $link->setConstructeur($constructeur);
    $link->setSupplierReference($supplierReference);
    $this->getEntityManager()->persist($link);
    $this->getEntityManager()->flush();

    return $link;
}

protected function createPieceConstructeurLink(Piece $piece, Constructeur $constructeur, ?string $supplierReference = null): PieceConstructeurLink
{
    $link = new PieceConstructeurLink();
    $link->setPiece($piece);
    $link->setConstructeur($constructeur);
    $link->setSupplierReference($supplierReference);
    $this->getEntityManager()->persist($link);
    $this->getEntityManager()->flush();

    return $link;
}

protected function createComposantConstructeurLink(Composant $composant, Constructeur $constructeur, ?string $supplierReference = null): ComposantConstructeurLink
{
    $link = new ComposantConstructeurLink();
    $link->setComposant($composant);
    $link->setConstructeur($constructeur);
    $link->setSupplierReference($supplierReference);
    $this->getEntityManager()->persist($link);
    $this->getEntityManager()->flush();

    return $link;
}

protected function createProductConstructeurLink(Product $product, Constructeur $constructeur, ?string $supplierReference = null): ProductConstructeurLink
{
    $link = new ProductConstructeurLink();
    $link->setProduct($product);
    $link->setConstructeur($constructeur);
    $link->setSupplierReference($supplierReference);
    $this->getEntityManager()->persist($link);
    $this->getEntityManager()->flush();

    return $link;
}

Add the use statements for all 4 Link entities.

  • Step 2: Update MCP test files

In each test file, replace $entity->addConstructeur($constructeur) with the factory helper. For example in MachinesCrudToolTest.php (line ~34):

Replace:

$machine->addConstructeur($constructeur);
$this->getEntityManager()->flush();

With:

$this->createMachineConstructeurLink($machine, $constructeur);

Update assertions to match the new response format (with supplierReference field).

Apply the same changes to PiecesCrudToolTest.php, ComposantsCrudToolTest.php, ProductsCrudToolTest.php.

  • Step 3: Run php-cs-fixer

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

  • Step 4: Commit
git add tests/
git commit -m "test(constructeur) : update test helpers and MCP tests for ConstructeurLinks"

Task 10: Run full test suite + fix any remaining issues

  • Step 1: Run the full test suite
make test

Expected: all tests pass. If any test fails due to references to the old getConstructeurs() / addConstructeur() / removeConstructeur() methods, fix them to use getConstructeurLinks() + Link entities.

  • Step 2: Fix any failures

Look for remaining usages of the old API and update them.

  • Step 3: Run php-cs-fixer one final time

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

  • Step 4: Final commit if needed
git add -A
git commit -m "fix(constructeur) : fix remaining references after ConstructeurLink migration"

Task 11: Update fixtures dump

  • Step 1: Dump updated fixtures

If the dev database has been migrated, dump the new state:

make fixtures-dump
  • Step 2: Commit
git add fixtures/
git commit -m "chore : update fixtures dump after ConstructeurLink migration"