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

1074 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
---
### Task 1: Create the 4 Link entities + repositories
**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
<?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
<?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**
```bash
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:
```php
/**
* @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:
```php
/**
* @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:
```php
/**
* @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:
```php
/**
* @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:
```php
/**
* @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:
```php
/**
* @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:
```php
/**
* @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:
```php
$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**
```bash
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:
```bash
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:
```sql
-- 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";
```
3. 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**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
```
Expected: migration passes without errors.
- [ ] **Step 4: Verify data**
```bash
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**
```bash
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:
```php
// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
$newMachine->getConstructeurs()->add($constructeur);
}
```
With:
```php
// 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:
```php
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
```
With:
```php
'constructeurs' => $this->normalizeConstructeurLinks($machine->getConstructeurLinks()),
```
- [ ] **Step 3: Update normalizeConstructeurs → normalizeConstructeurLinks (lines ~814-824)**
Replace the method:
```php
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:
```php
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**
```bash
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:
```php
'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**
```bash
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:
```php
$currentConstructeurIds = [];
if (method_exists($entity, 'getConstructeurs')) {
foreach ($entity->getConstructeurs() as $c) {
$currentConstructeurIds[] = $c->getId();
}
}
```
With:
```php
$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:
```php
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**
```bash
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`:
```php
// 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)**
```php
// 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**
```bash
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):
```php
foreach ($constructeurIds as $cId) {
$c = $this->constructeurs->find($cId);
if (!$c) {
$this->mcpError('not_found', "Constructeur not found: {$cId}");
}
$machine->addConstructeur($c);
}
```
With:
```php
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):
```php
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:
```php
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):
```php
$constructeurs = [];
foreach ($machine->getConstructeurs() as $c) {
$constructeurs[] = [
'id' => $c->getId(),
'name' => $c->getName(),
];
}
```
With:
```php
$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**
```bash
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:
```php
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:
```php
$machine->addConstructeur($constructeur);
$this->getEntityManager()->flush();
```
With:
```php
$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**
```bash
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**
```bash
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**
```bash
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:
```bash
make fixtures-dump
```
- [ ] **Step 2: Commit**
```bash
git add fixtures/
git commit -m "chore : update fixtures dump after ConstructeurLink migration"
```