Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions
c88333b052 chore : bump version to v1.9.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-05-03 18:05:16 +00:00
8f5cd98b82 fix(machine-clone) : preserve context field values when cloning a machine
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00
48f7e4c6ac test(session) : align expectations with hardened auth from WIP 476060c
Generic 'Identifiants invalides.' is now returned for both wrong
password and missing-password-set cases (security obscurity, prevents
account enumeration). Tests still asserted the granular 'Mot de passe
incorrect.' message and a 403 status that the controller no longer
emits.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:56:53 +02:00
c46769a67d fix(model-types) : nullify weak references on ModelType delete
Belt-and-suspenders against orphan refs when a ModelType is deleted:
applicatively nullifies typeComposantId / typePieceId / typeProductId
on every "ON DELETE SET NULL" relationship before the row is removed,
in case the database FK cascade fails to fire.

Observed in prod 2026-04-28: deletion of ModelType "Paliers" left an
orphan in skeleton_subcomponent_requirements, surfacing as a 500 when
API Platform tried to lazy-load the missing proxy.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:29:36 +02:00
gitea-actions
28394ce1b4 chore : bump version to v1.9.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m16s
2026-04-10 14:57:59 +00:00
Matthieu
8cfcb41a39 feat(conversion) : commande CLI pour convertir la catégorie Moteur de PIECE vers COMPONENT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Migre les 18 pièces en composants, transfère documents, custom fields,
slots et skeleton requirements dans une transaction. Supporte --dry-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:57:46 +02:00
6 changed files with 391 additions and 4 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.27'
app.version: '1.9.29'

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Command;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:convert-moteur-piece-to-component',
description: 'Convertit la catégorie "Moteur" (PIECE) en COMPONENT et migre toutes les pièces liées en composants.',
)]
class ConvertMoteurPieceToComponentCommand extends Command
{
private const MODEL_TYPE_ID = 'cmgytewe0002447ffup09bscr';
public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les actions sans les exécuter');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$io->title('Conversion catégorie "Moteur" : PIECE → COMPONENT');
// ── 1. Vérifications ──────────────────────────────────────────────
$modelType = $this->connection->fetchAssociative(
'SELECT id, name, code, category FROM model_types WHERE id = :id',
['id' => self::MODEL_TYPE_ID],
);
if (!$modelType) {
$io->error('ModelType "Moteur" introuvable (id: '.self::MODEL_TYPE_ID.')');
return Command::FAILURE;
}
if ('PIECE' !== $modelType['category']) {
$io->error(sprintf('Le ModelType "Moteur" est déjà de catégorie %s — rien à faire.', $modelType['category']));
return Command::FAILURE;
}
$pieces = $this->connection->fetchAllAssociative(
'SELECT id, name, reference FROM pieces WHERE typepieceid = :id ORDER BY name',
['id' => self::MODEL_TYPE_ID],
);
$pieceCount = count($pieces);
$io->info(sprintf('Pièces à convertir : %d', $pieceCount));
if ($pieceCount > 0) {
$io->table(
['ID', 'Nom', 'Référence'],
array_map(fn (array $p) => [$p['id'], $p['name'], $p['reference'] ?? '—'], $pieces),
);
}
// Check blockers
$blockers = [];
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_piece_links mpl
JOIN pieces p ON mpl.pieceid = p.id
WHERE p.typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines — conversion impossible.', $machineLinked);
}
$nameCollisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
WHERE p.typepieceid = :id
AND p.name IN (SELECT c.name FROM composants c)',
['id' => self::MODEL_TYPE_ID],
);
if ([] !== $nameCollisions) {
$blockers[] = sprintf('Collision de noms avec des composants existants : %s', implode(', ', $nameCollisions));
}
$categoryCollision = (int) $this->connection->fetchOne(
"SELECT COUNT(*) FROM model_types WHERE category = 'COMPONENT' AND name = :name AND id != :id",
['name' => $modelType['name'], 'id' => self::MODEL_TYPE_ID],
);
if ($categoryCollision > 0) {
$blockers[] = sprintf('Un ModelType composant « %s » existe déjà.', $modelType['name']);
}
if ([] !== $blockers) {
$io->error($blockers);
return Command::FAILURE;
}
// Summary of related data
$relatedCounts = $this->countRelatedData();
$io->section('Données liées à migrer');
$io->table(
['Table', 'Nombre'],
array_map(fn (string $k, int $v) => [$k, $v], array_keys($relatedCounts), array_values($relatedCounts)),
);
if ($dryRun) {
$io->warning('Mode dry-run : aucune modification effectuée.');
return Command::SUCCESS;
}
// ── 2. Exécution ──────────────────────────────────────────────────
$this->connection->beginTransaction();
try {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
// 2a. Copier les pièces dans composants
$converted = $this->connection->executeStatement(
'INSERT INTO composants (id, name, reference, referenceauto, description, prix, typecomposantid, productid, version, createdat, updatedat)
SELECT id, name, reference, referenceauto, description, prix, typepieceid, productid, version, createdat, :now
FROM pieces
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text(sprintf('✓ %d pièce(s) copiée(s) dans composants', $converted));
// 2b. Transférer les documents
$docs = $this->connection->executeStatement(
'UPDATE documents SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d document(s) transféré(s)', $docs));
// 2c. Transférer les custom_field_values
$cfv = $this->connection->executeStatement(
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d valeur(s) de champs perso transférée(s)', $cfv));
// 2d. Transférer les custom_fields (définitions)
$cf = $this->connection->executeStatement(
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d définition(s) de champs perso transférée(s)', $cf));
// 2e. Transférer les constructeur links
$ctorLinks = $this->connection->executeStatement(
"INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, :now
FROM piece_constructeur_links pcl
WHERE pcl.pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
if ($ctorLinks > 0) {
$this->connection->executeStatement(
'DELETE FROM piece_constructeur_links
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
}
$io->text(sprintf('✓ %d lien(s) constructeur transféré(s)', $ctorLinks));
// 2f. Convertir composant_piece_slots → composant_subcomponent_slots
$slots = $this->connection->executeStatement(
"INSERT INTO composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
cps.composantid,
COALESCE(sp.name, 'Moteur'),
'moteur',
cps.typepieceid,
cps.selectedpieceid,
cps.position,
cps.createdat,
:now
FROM composant_piece_slots cps
LEFT JOIN pieces sp ON sp.id = cps.selectedpieceid
WHERE cps.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM composant_piece_slots WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d slot(s) pièce convertis en slots sous-composant', $slots));
// 2g. Convertir skeleton_piece_requirements → skeleton_subcomponent_requirements
$skelReqs = $this->connection->executeStatement(
"INSERT INTO skeleton_subcomponent_requirements (id, modeltypeid, alias, familycode, typecomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
spr.modeltypeid,
'Moteur',
'moteur',
spr.typepieceid,
spr.position,
spr.createdat,
:now
FROM skeleton_piece_requirements spr
WHERE spr.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d skeleton requirement(s) convertis', $skelReqs));
// 2h. Mettre à jour audit_logs entity_type
$auditUpdated = $this->connection->executeStatement(
"UPDATE audit_logs SET entitytype = 'composant'
WHERE entitytype = 'piece'
AND entityid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d audit log(s) mis à jour', $auditUpdated));
// 2i. Mettre à jour comments entity_type
$commentsUpdated = $this->connection->executeStatement(
"UPDATE comments SET entity_type = 'composant'
WHERE entity_type = 'piece'
AND entity_id IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d commentaire(s) mis à jour', $commentsUpdated));
// 2j. Supprimer les pièces originales
$deleted = $this->connection->executeStatement(
'DELETE FROM pieces WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d pièce(s) supprimée(s)', $deleted));
// 2k. Changer la catégorie du ModelType
$this->connection->executeStatement(
"UPDATE model_types SET category = 'COMPONENT', updatedat = :now WHERE id = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text('✓ ModelType "Moteur" passé en COMPONENT');
$this->connection->commit();
$io->success(sprintf('Conversion terminée : %d pièces → composants.', $converted));
return Command::SUCCESS;
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Erreur — rollback effectué : '.$e->getMessage());
return Command::FAILURE;
}
}
/**
* @return array<string, int>
*/
private function countRelatedData(): array
{
$id = self::MODEL_TYPE_ID;
return [
'pieces' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
['id' => $id],
),
'documents' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM documents WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_field_values' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_field_values WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_fields' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_fields WHERE typepieceid = :id',
['id' => $id],
),
'piece_constructeur_links' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM piece_constructeur_links WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'composant_piece_slots (→ subcomponent_slots)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots WHERE typepieceid = :id',
['id' => $id],
),
'skeleton_piece_requirements (→ subcomponent_reqs)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => $id],
),
];
}
}

View File

@@ -337,6 +337,7 @@ class MachineStructureController extends AbstractController
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
}
@@ -352,6 +353,7 @@ class MachineStructureController extends AbstractController
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\ModelType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
use function sprintf;
/**
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
*/
#[AsDoctrineListener(event: Events::preRemove)]
final class ModelTypeReferenceCleanupSubscriber
{
/** @var list<array{0: string, 1: string}> */
private const NULLABLE_REFERENCES = [
['skeleton_subcomponent_requirements', 'typecomposantid'],
['skeleton_piece_requirements', 'typepieceid'],
['skeleton_product_requirements', 'typeproductid'],
['composant_piece_slots', 'typepieceid'],
['composant_product_slots', 'typeproductid'],
['composant_subcomponent_slots', 'typecomposantid'],
['piece_product_slots', 'typeproductid'],
['machine_component_links', 'modeltypeid'],
['machine_piece_links', 'modeltypeid'],
['machine_product_links', 'modeltypeid'],
];
public function preRemove(PreRemoveEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof ModelType) {
return;
}
$id = $entity->getId();
if (!$id) {
return;
}
$conn = $args->getObjectManager()->getConnection();
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
$conn->executeStatement(
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
[$id],
);
}
}
}

View File

@@ -7,10 +7,10 @@ namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
@@ -467,6 +467,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em->persist($cfv);
$em->flush();
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
$machineComponentLink->getContextFieldValues()->add($cfv);
}
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
$machinePieceLink->getContextFieldValues()->add($cfv);
}
return $cfv;
}

View File

@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
]);
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
$this->assertJsonContains(['message' => 'Identifiants invalides.']);
}
public function testLoginMissingPassword(): void
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
],
]);
$this->assertResponseStatusCodeSame(403);
$this->assertResponseStatusCodeSame(401);
}
public function testGetActiveProfileAuthenticated(): void