2448 lines
82 KiB
Markdown
2448 lines
82 KiB
Markdown
# Entity Versioning 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:** Add numbered versioning with restore capability to Machine, Composant, Piece, and Product entities, leveraging the existing AuditLog system.
|
|
|
|
**Architecture:** Extend AuditLog with a `version` column. Enrich snapshots to include slots/custom fields. Add `EntityVersionController` with list/preview/restore endpoints. New `EntityVersionService` centralizes restore logic with skeleton and integrity checks. Frontend gets a `EntityVersionList` component and `VersionRestoreModal`.
|
|
|
|
**Tech Stack:** Symfony 8 / PHP 8.4 / API Platform / PostgreSQL 16 / Nuxt 4 / Vue 3 / DaisyUI 5
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Backend — New Files
|
|
- `src/Service/EntityVersionService.php` — Core restore logic (skeleton check, integrity check, apply restore)
|
|
- `src/Controller/EntityVersionController.php` — REST endpoints (versions list, preview, restore)
|
|
- `migrations/VersionXXXX.php` — Add `version` column to `audit_logs` and `machines`
|
|
- `tests/Api/Controller/EntityVersionTest.php` — Full test coverage
|
|
|
|
### Backend — Modified Files
|
|
- `src/Entity/AuditLog.php` — Add `version` column + getter/setter
|
|
- `src/Entity/Machine.php` — Add `version` property + `getVersion()`/`incrementVersion()`
|
|
- `src/EventSubscriber/AbstractAuditSubscriber.php` — Auto-increment version + write to AuditLog
|
|
- `src/EventSubscriber/ComposantAuditSubscriber.php` — Enrich snapshot with slots + custom fields + version
|
|
- `src/EventSubscriber/PieceAuditSubscriber.php` — Enrich snapshot with productSlots + custom fields + version
|
|
- `src/EventSubscriber/ProductAuditSubscriber.php` — Enrich snapshot with custom fields + version
|
|
- `src/EventSubscriber/MachineAuditSubscriber.php` — Enrich snapshot with custom fields + version
|
|
- `src/Repository/AuditLogRepository.php` — Add `findVersionHistory()` method
|
|
|
|
### Frontend — New Files
|
|
- `frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore
|
|
- `frontend/app/components/common/EntityVersionList.vue` — Version list with restore button
|
|
- `frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal
|
|
|
|
### Frontend — Modified Files
|
|
- `frontend/app/pages/machine/[id].vue` — Add Versions section
|
|
- `frontend/app/pages/component/[id]/edit.vue` — Add Versions section
|
|
- `frontend/app/pages/piece/[id].vue` — Add Versions section
|
|
- `frontend/app/pages/product/[id]/edit.vue` — Add Versions section
|
|
|
|
---
|
|
|
|
## Task 1: Migration — version columns on audit_logs and machines
|
|
|
|
**Files:**
|
|
- Create: `migrations/Version20260325160000.php`
|
|
|
|
- [ ] **Step 1: Create the migration file**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
final class Version20260325160000 extends AbstractMigration
|
|
{
|
|
public function getDescription(): string
|
|
{
|
|
return 'Add version column to audit_logs and machines tables';
|
|
}
|
|
|
|
public function up(Schema $schema): void
|
|
{
|
|
$this->addSql('ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL');
|
|
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
|
$this->addSql('CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entitytype, entityid, version)');
|
|
}
|
|
|
|
public function down(Schema $schema): void
|
|
{
|
|
$this->addSql('DROP INDEX IF EXISTS idx_audit_entity_version');
|
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN IF EXISTS version');
|
|
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS version');
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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: Update test schema**
|
|
|
|
Run: `make test-setup`
|
|
Expected: Schema updated for test environment.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add migrations/Version20260325160000.php
|
|
git commit -m "feat(versioning) : add version column to audit_logs and machines"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: AuditLog entity — add version property
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/AuditLog.php`
|
|
|
|
- [ ] **Step 1: Add the version column property after actorProfileId (line 39)**
|
|
|
|
Add after the `$actorProfileId` property:
|
|
|
|
```php
|
|
#[ORM\Column(type: Types::INTEGER, nullable: true)]
|
|
private ?int $version = null;
|
|
```
|
|
|
|
- [ ] **Step 2: Add version to constructor parameters**
|
|
|
|
Update the constructor signature to accept version:
|
|
|
|
```php
|
|
public function __construct(
|
|
string $entityType,
|
|
string $entityId,
|
|
string $action,
|
|
?array $diff = null,
|
|
?array $snapshot = null,
|
|
?string $actorProfileId = null,
|
|
?int $version = null,
|
|
) {
|
|
$this->entityType = $entityType;
|
|
$this->entityId = $entityId;
|
|
$this->action = $action;
|
|
$this->diff = $diff;
|
|
$this->snapshot = $snapshot;
|
|
$this->actorProfileId = $actorProfileId;
|
|
$this->version = $version;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add getter and setter methods after getCreatedAt()**
|
|
|
|
```php
|
|
public function getVersion(): ?int
|
|
{
|
|
return $this->version;
|
|
}
|
|
|
|
public function setVersion(?int $version): static
|
|
{
|
|
$this->version = $version;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 5: Run tests to verify no regressions**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/AuditLog.php
|
|
git commit -m "feat(versioning) : add version property to AuditLog entity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Machine entity — add version property
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Machine.php`
|
|
|
|
- [ ] **Step 1: Add version property after updatedAt (line 112)**
|
|
|
|
```php
|
|
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
|
private int $version = 1;
|
|
```
|
|
|
|
- [ ] **Step 2: Add getter and incrementVersion methods after getCustomFieldValues()**
|
|
|
|
```php
|
|
public function getVersion(): int
|
|
{
|
|
return $this->version;
|
|
}
|
|
|
|
public function incrementVersion(): static
|
|
{
|
|
++$this->version;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass (Machine tests now see version column).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Machine.php
|
|
git commit -m "feat(versioning) : add version property to Machine entity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: AbstractAuditSubscriber — auto-increment version + write to AuditLog
|
|
|
|
**Files:**
|
|
- Modify: `src/EventSubscriber/AbstractAuditSubscriber.php`
|
|
|
|
- [ ] **Step 1: Add a helper method to extract and increment version**
|
|
|
|
Add after the `resolveActorProfileId()` method (after line 252):
|
|
|
|
```php
|
|
/**
|
|
* If the entity has a version, increment it and return the new value.
|
|
* Recomputes the changeset so Doctrine picks up the version bump.
|
|
*/
|
|
protected function incrementEntityVersion(object $entity, EntityManagerInterface $em, UnitOfWork $uow): ?int
|
|
{
|
|
if (!method_exists($entity, 'incrementVersion') || !method_exists($entity, 'getVersion')) {
|
|
return null;
|
|
}
|
|
|
|
$entity->incrementVersion();
|
|
$uow->recomputeSingleEntityChangeSet(
|
|
$em->getClassMetadata($entity::class),
|
|
$entity,
|
|
);
|
|
|
|
return $entity->getVersion();
|
|
}
|
|
|
|
/**
|
|
* Get the current version without incrementing (for create actions).
|
|
*/
|
|
protected function getEntityVersion(object $entity): ?int
|
|
{
|
|
if (!method_exists($entity, 'getVersion')) {
|
|
return null;
|
|
}
|
|
|
|
return $entity->getVersion();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Modify onFlushSimple — add version to AuditLog on create and update**
|
|
|
|
Replace `onFlushSimple()` (lines 254-291):
|
|
|
|
```php
|
|
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
|
{
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$version = $this->getEntityVersion($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$id = (string) $entity->getId();
|
|
if ('' === $id) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
if ([] !== $diff) {
|
|
$version = $this->incrementEntityVersion($entity, $em, $uow);
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Modify onFlushComplex — add version to AuditLog on create and final persist**
|
|
|
|
Replace `onFlushComplex()` (lines 293-358):
|
|
|
|
```php
|
|
private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
|
{
|
|
$pendingUpdates = [];
|
|
$pendingSnapshots = [];
|
|
$pendingEntities = [];
|
|
|
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$version = $this->getEntityVersion($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$entityId = (string) $entity->getId();
|
|
if ('' === $entityId) {
|
|
continue;
|
|
}
|
|
|
|
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
|
if ([] !== $diff) {
|
|
$pendingUpdates[$entityId] = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
|
|
$pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
|
|
$pendingEntities[$entityId] = $entity;
|
|
}
|
|
}
|
|
|
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
|
if (!$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
|
}
|
|
|
|
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
|
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
}
|
|
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
|
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
}
|
|
|
|
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
|
|
foreach ($pendingUpdates as $entityId => $diff) {
|
|
if ([] === $diff) {
|
|
continue;
|
|
}
|
|
|
|
$entity = $pendingEntities[$entityId] ?? null;
|
|
if (null === $entity || !$this->supports($entity)) {
|
|
continue;
|
|
}
|
|
|
|
$version = $this->incrementEntityVersion($entity, $em, $uow);
|
|
// Re-take snapshot after version increment so it captures the new version number
|
|
$snapshot = $this->snapshotEntity($entity);
|
|
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId, $version));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass. Version auto-increments on update.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/EventSubscriber/AbstractAuditSubscriber.php
|
|
git commit -m "feat(versioning) : auto-increment version on entity updates in audit subscribers"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Enrich audit snapshots with slots + custom fields
|
|
|
|
**Files:**
|
|
- Modify: `src/EventSubscriber/ComposantAuditSubscriber.php`
|
|
- Modify: `src/EventSubscriber/PieceAuditSubscriber.php`
|
|
- Modify: `src/EventSubscriber/ProductAuditSubscriber.php`
|
|
- Modify: `src/EventSubscriber/MachineAuditSubscriber.php`
|
|
|
|
- [ ] **Step 1: Enrich ComposantAuditSubscriber snapshotEntity()**
|
|
|
|
Replace `snapshotEntity()` in `ComposantAuditSubscriber.php`:
|
|
|
|
```php
|
|
protected function snapshotEntity(object $entity): array
|
|
{
|
|
$pieceSlots = [];
|
|
foreach ($entity->getPieceSlots() as $slot) {
|
|
$pieceSlots[] = [
|
|
'id' => $slot->getId(),
|
|
'typePieceId' => $slot->getTypePiece()?->getId(),
|
|
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
|
'quantity' => $slot->getQuantity(),
|
|
'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
|
|
$subcomponentSlots = [];
|
|
foreach ($entity->getSubcomponentSlots() as $slot) {
|
|
$subcomponentSlots[] = [
|
|
'id' => $slot->getId(),
|
|
'alias' => $slot->getAlias(),
|
|
'familyCode' => $slot->getFamilyCode(),
|
|
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
|
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
|
'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
|
|
$productSlots = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$productSlots[] = [
|
|
'id' => $slot->getId(),
|
|
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(),
|
|
'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
|
|
$customFieldValues = [];
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$customFieldValues[] = [
|
|
'id' => $cfv->getId(),
|
|
'fieldName' => $cfv->getCustomField()?->getName(),
|
|
'fieldId' => $cfv->getCustomField()?->getId(),
|
|
'value' => $cfv->getValue(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $entity->getId(),
|
|
'name' => $this->safeGet($entity, 'getName'),
|
|
'reference' => $this->safeGet($entity, 'getReference'),
|
|
'description' => $this->safeGet($entity, 'getDescription'),
|
|
'prix' => $this->safeGet($entity, 'getPrix'),
|
|
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
|
|
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
|
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
|
'pieceSlots' => $pieceSlots,
|
|
'subcomponentSlots'=> $subcomponentSlots,
|
|
'productSlots' => $productSlots,
|
|
'customFieldValues'=> $customFieldValues,
|
|
'version' => $this->safeGet($entity, 'getVersion'),
|
|
];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Enrich PieceAuditSubscriber snapshotEntity()**
|
|
|
|
Replace `snapshotEntity()` in `PieceAuditSubscriber.php`:
|
|
|
|
```php
|
|
protected function snapshotEntity(object $entity): array
|
|
{
|
|
$productSlots = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$productSlots[] = [
|
|
'id' => $slot->getId(),
|
|
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(),
|
|
'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
|
|
$customFieldValues = [];
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$customFieldValues[] = [
|
|
'id' => $cfv->getId(),
|
|
'fieldName' => $cfv->getCustomField()?->getName(),
|
|
'fieldId' => $cfv->getCustomField()?->getId(),
|
|
'value' => $cfv->getValue(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $entity->getId(),
|
|
'name' => $this->safeGet($entity, 'getName'),
|
|
'reference' => $this->safeGet($entity, 'getReference'),
|
|
'description' => $this->safeGet($entity, 'getDescription'),
|
|
'prix' => $this->safeGet($entity, 'getPrix'),
|
|
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
|
|
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
|
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
|
'productSlots' => $productSlots,
|
|
'customFieldValues'=> $customFieldValues,
|
|
'version' => $this->safeGet($entity, 'getVersion'),
|
|
];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Enrich ProductAuditSubscriber snapshotEntity()**
|
|
|
|
Replace `snapshotEntity()` in `ProductAuditSubscriber.php`:
|
|
|
|
```php
|
|
protected function snapshotEntity(object $entity): array
|
|
{
|
|
$customFieldValues = [];
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$customFieldValues[] = [
|
|
'id' => $cfv->getId(),
|
|
'fieldName' => $cfv->getCustomField()?->getName(),
|
|
'fieldId' => $cfv->getCustomField()?->getId(),
|
|
'value' => $cfv->getValue(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $entity->getId(),
|
|
'name' => $this->safeGet($entity, 'getName'),
|
|
'reference' => $this->safeGet($entity, 'getReference'),
|
|
'supplierPrice' => $this->safeGet($entity, 'getSupplierPrice'),
|
|
'typeProduct' => $this->normalizeValue($this->safeGet($entity, 'getTypeProduct')),
|
|
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
|
'customFieldValues'=> $customFieldValues,
|
|
'version' => $this->safeGet($entity, 'getVersion'),
|
|
];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Enrich MachineAuditSubscriber snapshotEntity()**
|
|
|
|
Replace `snapshotEntity()` in `MachineAuditSubscriber.php`:
|
|
|
|
```php
|
|
protected function snapshotEntity(object $entity): array
|
|
{
|
|
$customFieldValues = [];
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$customFieldValues[] = [
|
|
'id' => $cfv->getId(),
|
|
'fieldName' => $cfv->getCustomField()?->getName(),
|
|
'fieldId' => $cfv->getCustomField()?->getId(),
|
|
'value' => $cfv->getValue(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $entity->getId(),
|
|
'name' => $this->safeGet($entity, 'getName'),
|
|
'reference' => $this->safeGet($entity, 'getReference'),
|
|
'prix' => $this->safeGet($entity, 'getPrix'),
|
|
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
|
|
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
|
'customFieldValues'=> $customFieldValues,
|
|
'version' => $this->safeGet($entity, 'getVersion'),
|
|
];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 6: Run tests**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass. Snapshots now include enriched data.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/EventSubscriber/ComposantAuditSubscriber.php src/EventSubscriber/PieceAuditSubscriber.php src/EventSubscriber/ProductAuditSubscriber.php src/EventSubscriber/MachineAuditSubscriber.php
|
|
git commit -m "feat(versioning) : enrich audit snapshots with slots, custom fields and version"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: AuditLogRepository — add findVersionHistory()
|
|
|
|
**Files:**
|
|
- Modify: `src/Repository/AuditLogRepository.php`
|
|
|
|
- [ ] **Step 1: Add findVersionHistory method**
|
|
|
|
Add after `findEntityHistory()`:
|
|
|
|
```php
|
|
/**
|
|
* @return list<AuditLog>
|
|
*/
|
|
public function findVersionHistory(string $entityType, string $entityId): array
|
|
{
|
|
return $this->createQueryBuilder('a')
|
|
->andWhere('a.entityType = :entityType')
|
|
->andWhere('a.entityId = :entityId')
|
|
->andWhere('a.version IS NOT NULL')
|
|
->setParameter('entityType', $entityType)
|
|
->setParameter('entityId', $entityId)
|
|
->orderBy('a.version', 'DESC')
|
|
->getQuery()
|
|
->getResult()
|
|
;
|
|
}
|
|
|
|
public function findByVersion(string $entityType, string $entityId, int $version): ?AuditLog
|
|
{
|
|
return $this->createQueryBuilder('a')
|
|
->andWhere('a.entityType = :entityType')
|
|
->andWhere('a.entityId = :entityId')
|
|
->andWhere('a.version = :version')
|
|
->setParameter('entityType', $entityType)
|
|
->setParameter('entityId', $entityId)
|
|
->setParameter('version', $version)
|
|
->setMaxResults(1)
|
|
->getQuery()
|
|
->getOneOrNullResult()
|
|
;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Repository/AuditLogRepository.php
|
|
git commit -m "feat(versioning) : add findVersionHistory and findByVersion to AuditLogRepository"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: EntityVersionService — core restore logic
|
|
|
|
**Files:**
|
|
- Create: `src/Service/EntityVersionService.php`
|
|
|
|
- [ ] **Step 1: Create the service**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\AuditLog;
|
|
use App\Entity\Composant;
|
|
use App\Entity\ComposantPieceSlot;
|
|
use App\Entity\ComposantProductSlot;
|
|
use App\Entity\ComposantSubcomponentSlot;
|
|
use App\Entity\Machine;
|
|
use App\Entity\Piece;
|
|
use App\Entity\PieceProductSlot;
|
|
use App\Entity\Product;
|
|
use App\Repository\AuditLogRepository;
|
|
use App\Repository\ComposantRepository;
|
|
use App\Repository\ConstructeurRepository;
|
|
use App\Repository\CustomFieldValueRepository;
|
|
use App\Repository\MachineRepository;
|
|
use App\Repository\ModelTypeRepository;
|
|
use App\Repository\PieceRepository;
|
|
use App\Repository\ProductRepository;
|
|
use App\Repository\ProfileRepository;
|
|
use App\Repository\SiteRepository;
|
|
use DateTimeInterface;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
|
|
final class EntityVersionService
|
|
{
|
|
private const ENTITY_MAP = [
|
|
'machine' => Machine::class,
|
|
'composant' => Composant::class,
|
|
'piece' => Piece::class,
|
|
'product' => Product::class,
|
|
];
|
|
|
|
private const BASE_FIELDS = [
|
|
'machine' => ['name', 'reference', 'prix'],
|
|
'composant' => ['name', 'reference', 'description', 'prix'],
|
|
'piece' => ['name', 'reference', 'description', 'prix'],
|
|
'product' => ['name', 'reference', 'supplierPrice'],
|
|
];
|
|
|
|
public function __construct(
|
|
private readonly AuditLogRepository $auditLogs,
|
|
private readonly EntityManagerInterface $em,
|
|
private readonly RequestStack $requestStack,
|
|
private readonly MachineRepository $machines,
|
|
private readonly ComposantRepository $composants,
|
|
private readonly PieceRepository $pieces,
|
|
private readonly ProductRepository $products,
|
|
private readonly ConstructeurRepository $constructeurs,
|
|
private readonly SiteRepository $sites,
|
|
private readonly ModelTypeRepository $modelTypes,
|
|
private readonly CustomFieldValueRepository $customFieldValues,
|
|
private readonly ProfileRepository $profiles,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{items: list<array>, total: int}
|
|
*/
|
|
public function getVersions(string $entityType, string $entityId): array
|
|
{
|
|
$logs = $this->auditLogs->findVersionHistory($entityType, $entityId);
|
|
|
|
$actorIds = array_values(array_unique(array_filter(array_map(
|
|
static fn (AuditLog $log) => $log->getActorProfileId(),
|
|
$logs,
|
|
))));
|
|
|
|
$actorMap = [];
|
|
if ([] !== $actorIds) {
|
|
$profileEntities = $this->profiles->findBy(['id' => $actorIds]);
|
|
foreach ($profileEntities as $profile) {
|
|
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
|
if ('' === $label) {
|
|
$label = $profile->getEmail() ?? $profile->getId();
|
|
}
|
|
$actorMap[$profile->getId()] = $label;
|
|
}
|
|
}
|
|
|
|
$items = array_map(
|
|
static function (AuditLog $log) use ($actorMap) {
|
|
$actorId = $log->getActorProfileId();
|
|
|
|
return [
|
|
'version' => $log->getVersion(),
|
|
'action' => $log->getAction(),
|
|
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
|
'actor' => $actorId
|
|
? ['id' => $actorId, 'label' => $actorMap[$actorId] ?? $actorId]
|
|
: null,
|
|
'diff' => $log->getDiff(),
|
|
];
|
|
},
|
|
$logs,
|
|
);
|
|
|
|
return ['items' => array_values($items), 'total' => count($items)];
|
|
}
|
|
|
|
/**
|
|
* @return array{version: int, restoreMode: string, diff: array, warnings: list<array>, snapshot: array}
|
|
*/
|
|
public function getRestorePreview(string $entityType, string $entityId, int $version): array
|
|
{
|
|
$entity = $this->findEntity($entityType, $entityId);
|
|
if (null === $entity) {
|
|
throw new \InvalidArgumentException('Entité introuvable.');
|
|
}
|
|
|
|
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
|
|
if (null === $auditLog) {
|
|
throw new \InvalidArgumentException('Version introuvable.');
|
|
}
|
|
|
|
$snapshot = $auditLog->getSnapshot() ?? [];
|
|
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
|
|
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
|
|
$diff = $this->buildRestoreDiff($entityType, $entity, $snapshot, $restoreMode);
|
|
|
|
return [
|
|
'version' => $version,
|
|
'restoreMode' => $restoreMode,
|
|
'diff' => $diff,
|
|
'warnings' => $warnings,
|
|
'snapshot' => $snapshot,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: true, newVersion: int, restoredFromVersion: int, restoreMode: string, warnings: list<array>}
|
|
*/
|
|
public function restore(string $entityType, string $entityId, int $version): array
|
|
{
|
|
$entity = $this->findEntity($entityType, $entityId);
|
|
if (null === $entity) {
|
|
throw new \InvalidArgumentException('Entité introuvable.');
|
|
}
|
|
|
|
$auditLog = $this->auditLogs->findByVersion($entityType, $entityId, $version);
|
|
if (null === $auditLog) {
|
|
throw new \InvalidArgumentException('Version introuvable.');
|
|
}
|
|
|
|
$snapshot = $auditLog->getSnapshot() ?? [];
|
|
$restoreMode = $this->checkSkeletonCompatibility($entityType, $entity, $snapshot);
|
|
$warnings = $this->checkIntegrity($entityType, $snapshot, $restoreMode);
|
|
|
|
$connection = $this->em->getConnection();
|
|
$connection->beginTransaction();
|
|
|
|
try {
|
|
// Mark entity to skip audit subscriber (we create the AuditLog manually)
|
|
if (method_exists($entity, 'setSkipAudit')) {
|
|
$entity->setSkipAudit(true);
|
|
}
|
|
|
|
$this->applyRestore($entityType, $entity, $snapshot, $restoreMode);
|
|
|
|
// Increment version manually (since subscriber is skipped)
|
|
if (method_exists($entity, 'incrementVersion')) {
|
|
$entity->incrementVersion();
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
$newVersion = method_exists($entity, 'getVersion') ? $entity->getVersion() : null;
|
|
|
|
// Create the restore AuditLog manually with action = "restore"
|
|
$restoreAuditLog = new AuditLog(
|
|
$entityType,
|
|
$entityId,
|
|
'restore',
|
|
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
|
$this->buildCurrentSnapshot($entityType, $entity),
|
|
$this->resolveActorProfileId(),
|
|
$newVersion,
|
|
);
|
|
$this->em->persist($restoreAuditLog);
|
|
$this->em->flush();
|
|
|
|
$connection->commit();
|
|
} catch (\Throwable $e) {
|
|
$connection->rollBack();
|
|
throw $e;
|
|
} finally {
|
|
// Clear skip flag
|
|
if (method_exists($entity, 'setSkipAudit')) {
|
|
$entity->setSkipAudit(false);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'newVersion' => $newVersion,
|
|
'restoredFromVersion' => $version,
|
|
'restoreMode' => $restoreMode,
|
|
'warnings' => $warnings,
|
|
];
|
|
}
|
|
|
|
private function findEntity(string $entityType, string $entityId): ?object
|
|
{
|
|
return match ($entityType) {
|
|
'machine' => $this->machines->find($entityId),
|
|
'composant' => $this->composants->find($entityId),
|
|
'piece' => $this->pieces->find($entityId),
|
|
'product' => $this->products->find($entityId),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function checkSkeletonCompatibility(string $entityType, object $entity, array $snapshot): string
|
|
{
|
|
if ('machine' === $entityType) {
|
|
return 'full';
|
|
}
|
|
|
|
$currentTypeId = match ($entityType) {
|
|
'composant' => $entity->getTypeComposant()?->getId(),
|
|
'piece' => $entity->getTypePiece()?->getId(),
|
|
'product' => $entity->getTypeProduct()?->getId(),
|
|
default => null,
|
|
};
|
|
|
|
$typeKey = match ($entityType) {
|
|
'composant' => 'typeComposant',
|
|
'piece' => 'typePiece',
|
|
'product' => 'typeProduct',
|
|
default => null,
|
|
};
|
|
|
|
$snapshotTypeId = null;
|
|
if ($typeKey && isset($snapshot[$typeKey])) {
|
|
$snapshotTypeId = is_array($snapshot[$typeKey]) ? ($snapshot[$typeKey]['id'] ?? null) : $snapshot[$typeKey];
|
|
}
|
|
|
|
return $currentTypeId === $snapshotTypeId ? 'full' : 'partial';
|
|
}
|
|
|
|
private function checkIntegrity(string $entityType, array $snapshot, string $restoreMode): array
|
|
{
|
|
$warnings = [];
|
|
|
|
// Check constructeurs (batch query)
|
|
if (!empty($snapshot['constructeurIds'])) {
|
|
$constructeurEntries = [];
|
|
foreach ($snapshot['constructeurIds'] as $entry) {
|
|
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
|
|
$name = is_array($entry) ? ($entry['name'] ?? $id) : $id;
|
|
if ($id) {
|
|
$constructeurEntries[$id] = $name;
|
|
}
|
|
}
|
|
if ([] !== $constructeurEntries) {
|
|
$foundIds = array_map(
|
|
fn ($c) => $c->getId(),
|
|
$this->constructeurs->findBy(['id' => array_keys($constructeurEntries)]),
|
|
);
|
|
foreach ($constructeurEntries as $id => $name) {
|
|
if (!in_array($id, $foundIds, true)) {
|
|
$warnings[] = [
|
|
'field' => 'constructeurIds',
|
|
'message' => sprintf("Le fournisseur '%s' n'existe plus. Il ne sera pas restauré.", $name),
|
|
'missingEntityId' => $id,
|
|
'missingEntityName' => $name,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Machine: check site
|
|
if ('machine' === $entityType && !empty($snapshot['site'])) {
|
|
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
|
|
if ($siteId && null === $this->sites->find($siteId)) {
|
|
$siteName = is_array($snapshot['site']) ? ($snapshot['site']['name'] ?? $siteId) : $siteId;
|
|
$warnings[] = [
|
|
'field' => 'site',
|
|
'message' => sprintf("Le site '%s' n'existe plus. Le site actuel sera conservé.", $siteName),
|
|
'missingEntityId' => $siteId,
|
|
'missingEntityName' => $siteName,
|
|
];
|
|
}
|
|
}
|
|
|
|
if ('partial' === $restoreMode) {
|
|
return $warnings;
|
|
}
|
|
|
|
// Full mode: check slot references (batch queries per slot type)
|
|
$slotChecks = match ($entityType) {
|
|
'composant' => [
|
|
['key' => 'pieceSlots', 'refKey' => 'selectedPieceId', 'label' => 'pièce', 'repo' => $this->pieces],
|
|
['key' => 'subcomponentSlots', 'refKey' => 'selectedComposantId', 'label' => 'sous-composant', 'repo' => $this->composants],
|
|
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
|
|
],
|
|
'piece' => [
|
|
['key' => 'productSlots', 'refKey' => 'selectedProductId', 'label' => 'produit', 'repo' => $this->products],
|
|
],
|
|
default => [],
|
|
};
|
|
|
|
foreach ($slotChecks as $check) {
|
|
$slots = $snapshot[$check['key']] ?? [];
|
|
// Collect all referenced IDs for batch lookup
|
|
$refIds = [];
|
|
foreach ($slots as $i => $slot) {
|
|
$refId = $slot[$check['refKey']] ?? null;
|
|
if (null !== $refId) {
|
|
$refIds[$i] = $refId;
|
|
}
|
|
}
|
|
if ([] === $refIds) {
|
|
continue;
|
|
}
|
|
$foundEntities = $check['repo']->findBy(['id' => array_values(array_unique($refIds))]);
|
|
$foundIds = array_map(fn ($e) => $e->getId(), $foundEntities);
|
|
foreach ($refIds as $i => $refId) {
|
|
if (!in_array($refId, $foundIds, true)) {
|
|
$warnings[] = [
|
|
'field' => sprintf('%s[%d].%s', $check['key'], $i, $check['refKey']),
|
|
'message' => sprintf("Le %s sélectionné dans le slot n'existe plus. Le slot sera restauré vide.", $check['label']),
|
|
'missingEntityId' => $refId,
|
|
'missingEntityName' => null,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $warnings;
|
|
}
|
|
|
|
private function buildRestoreDiff(string $entityType, object $entity, array $snapshot, string $restoreMode): array
|
|
{
|
|
$diff = [];
|
|
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
|
|
|
|
foreach ($baseFields as $field) {
|
|
$getter = 'get'.ucfirst($field);
|
|
if (!method_exists($entity, $getter)) {
|
|
continue;
|
|
}
|
|
$current = $entity->{$getter}();
|
|
$restored = $snapshot[$field] ?? null;
|
|
if ((string) ($current ?? '') !== (string) ($restored ?? '')) {
|
|
$diff[$field] = ['current' => $current, 'restored' => $restored];
|
|
}
|
|
}
|
|
|
|
// Constructeurs
|
|
$currentConstructeurIds = [];
|
|
if (method_exists($entity, 'getConstructeurs')) {
|
|
foreach ($entity->getConstructeurs() as $c) {
|
|
$currentConstructeurIds[] = $c->getId();
|
|
}
|
|
}
|
|
$snapshotConstructeurIds = [];
|
|
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
|
|
$snapshotConstructeurIds[] = is_array($entry) ? ($entry['id'] ?? null) : $entry;
|
|
}
|
|
sort($currentConstructeurIds);
|
|
sort($snapshotConstructeurIds);
|
|
if ($currentConstructeurIds !== $snapshotConstructeurIds) {
|
|
$diff['constructeurIds'] = ['current' => $currentConstructeurIds, 'restored' => $snapshotConstructeurIds];
|
|
}
|
|
|
|
if ('full' === $restoreMode) {
|
|
// We signal slot restore as a single diff entry
|
|
$slotKeys = match ($entityType) {
|
|
'composant' => ['pieceSlots', 'subcomponentSlots', 'productSlots'],
|
|
'piece' => ['productSlots'],
|
|
default => [],
|
|
};
|
|
foreach ($slotKeys as $key) {
|
|
if (!empty($snapshot[$key])) {
|
|
$diff[$key] = ['current' => '(structure actuelle)', 'restored' => sprintf('%d slot(s)', count($snapshot[$key]))];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $diff;
|
|
}
|
|
|
|
private function applyRestore(string $entityType, object $entity, array $snapshot, string $restoreMode): void
|
|
{
|
|
// Restore base fields
|
|
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
|
|
foreach ($baseFields as $field) {
|
|
$setter = 'set'.ucfirst($field);
|
|
if (method_exists($entity, $setter) && array_key_exists($field, $snapshot)) {
|
|
$entity->{$setter}($snapshot[$field]);
|
|
}
|
|
}
|
|
|
|
// Restore constructeurs
|
|
$this->restoreConstructeurs($entity, $snapshot);
|
|
|
|
// Machine: restore site
|
|
if ('machine' === $entityType && isset($snapshot['site'])) {
|
|
$siteId = is_array($snapshot['site']) ? ($snapshot['site']['id'] ?? null) : $snapshot['site'];
|
|
if ($siteId) {
|
|
$site = $this->sites->find($siteId);
|
|
if (null !== $site) {
|
|
$entity->setSite($site);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ('partial' === $restoreMode) {
|
|
return;
|
|
}
|
|
|
|
// Full mode: restore slots
|
|
match ($entityType) {
|
|
'composant' => $this->restoreComposantSlots($entity, $snapshot),
|
|
'piece' => $this->restorePieceSlots($entity, $snapshot),
|
|
default => null,
|
|
};
|
|
|
|
// Full mode: restore custom field values
|
|
$this->restoreCustomFieldValues($entityType, $entity, $snapshot);
|
|
}
|
|
|
|
private function restoreConstructeurs(object $entity, array $snapshot): void
|
|
{
|
|
if (!method_exists($entity, 'getConstructeurs') || !method_exists($entity, 'addConstructeur') || !method_exists($entity, 'removeConstructeur')) {
|
|
return;
|
|
}
|
|
|
|
$targetIds = [];
|
|
foreach ($snapshot['constructeurIds'] ?? [] as $entry) {
|
|
$id = is_array($entry) ? ($entry['id'] ?? null) : $entry;
|
|
if ($id) {
|
|
$targetIds[] = $id;
|
|
}
|
|
}
|
|
|
|
// Remove current constructeurs not in snapshot
|
|
foreach ($entity->getConstructeurs()->toArray() as $c) {
|
|
if (!in_array($c->getId(), $targetIds, true)) {
|
|
$entity->removeConstructeur($c);
|
|
}
|
|
}
|
|
|
|
// Add missing constructeurs from snapshot
|
|
$currentIds = array_map(fn ($c) => $c->getId(), $entity->getConstructeurs()->toArray());
|
|
foreach ($targetIds as $id) {
|
|
if (!in_array($id, $currentIds, true)) {
|
|
$constructeur = $this->constructeurs->find($id);
|
|
if (null !== $constructeur) {
|
|
$entity->addConstructeur($constructeur);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function restoreComposantSlots(Composant $entity, array $snapshot): void
|
|
{
|
|
// Clear existing slots
|
|
foreach ($entity->getPieceSlots()->toArray() as $slot) {
|
|
$this->em->remove($slot);
|
|
}
|
|
foreach ($entity->getSubcomponentSlots()->toArray() as $slot) {
|
|
$this->em->remove($slot);
|
|
}
|
|
foreach ($entity->getProductSlots()->toArray() as $slot) {
|
|
$this->em->remove($slot);
|
|
}
|
|
|
|
// Flush removals first to avoid FK constraint conflicts with new slots
|
|
$this->em->flush();
|
|
|
|
// Recreate piece slots
|
|
foreach ($snapshot['pieceSlots'] ?? [] as $slotData) {
|
|
$slot = new ComposantPieceSlot();
|
|
$slot->setComposant($entity);
|
|
$slot->setQuantity($slotData['quantity'] ?? 1);
|
|
$slot->setPosition($slotData['position'] ?? 0);
|
|
if (!empty($slotData['typePieceId'])) {
|
|
$slot->setTypePiece($this->modelTypes->find($slotData['typePieceId']));
|
|
}
|
|
if (!empty($slotData['selectedPieceId'])) {
|
|
$piece = $this->pieces->find($slotData['selectedPieceId']);
|
|
if (null !== $piece) {
|
|
$slot->setSelectedPiece($piece);
|
|
}
|
|
}
|
|
$this->em->persist($slot);
|
|
}
|
|
|
|
// Recreate subcomponent slots
|
|
foreach ($snapshot['subcomponentSlots'] ?? [] as $slotData) {
|
|
$slot = new ComposantSubcomponentSlot();
|
|
$slot->setComposant($entity);
|
|
$slot->setAlias($slotData['alias'] ?? null);
|
|
$slot->setFamilyCode($slotData['familyCode'] ?? null);
|
|
$slot->setPosition($slotData['position'] ?? 0);
|
|
if (!empty($slotData['typeComposantId'])) {
|
|
$slot->setTypeComposant($this->modelTypes->find($slotData['typeComposantId']));
|
|
}
|
|
if (!empty($slotData['selectedComposantId'])) {
|
|
$composant = $this->composants->find($slotData['selectedComposantId']);
|
|
if (null !== $composant) {
|
|
$slot->setSelectedComposant($composant);
|
|
}
|
|
}
|
|
$this->em->persist($slot);
|
|
}
|
|
|
|
// Recreate product slots
|
|
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
|
|
$slot = new ComposantProductSlot();
|
|
$slot->setComposant($entity);
|
|
$slot->setFamilyCode($slotData['familyCode'] ?? null);
|
|
$slot->setPosition($slotData['position'] ?? 0);
|
|
if (!empty($slotData['typeProductId'])) {
|
|
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
|
|
}
|
|
if (!empty($slotData['selectedProductId'])) {
|
|
$product = $this->products->find($slotData['selectedProductId']);
|
|
if (null !== $product) {
|
|
$slot->setSelectedProduct($product);
|
|
}
|
|
}
|
|
$this->em->persist($slot);
|
|
}
|
|
}
|
|
|
|
private function restorePieceSlots(Piece $entity, array $snapshot): void
|
|
{
|
|
foreach ($entity->getProductSlots()->toArray() as $slot) {
|
|
$this->em->remove($slot);
|
|
}
|
|
|
|
// Flush removals first to avoid FK constraint conflicts
|
|
$this->em->flush();
|
|
|
|
foreach ($snapshot['productSlots'] ?? [] as $slotData) {
|
|
$slot = new PieceProductSlot();
|
|
$slot->setPiece($entity);
|
|
$slot->setFamilyCode($slotData['familyCode'] ?? null);
|
|
$slot->setPosition($slotData['position'] ?? 0);
|
|
if (!empty($slotData['typeProductId'])) {
|
|
$slot->setTypeProduct($this->modelTypes->find($slotData['typeProductId']));
|
|
}
|
|
if (!empty($slotData['selectedProductId'])) {
|
|
$product = $this->products->find($slotData['selectedProductId']);
|
|
if (null !== $product) {
|
|
$slot->setSelectedProduct($product);
|
|
}
|
|
}
|
|
$this->em->persist($slot);
|
|
}
|
|
}
|
|
|
|
private function restoreCustomFieldValues(string $entityType, object $entity, array $snapshot): void
|
|
{
|
|
$snapshotCfvs = $snapshot['customFieldValues'] ?? [];
|
|
if ([] === $snapshotCfvs) {
|
|
return;
|
|
}
|
|
|
|
// Build a map of current CFVs by fieldId for lookup
|
|
$currentCfvsByFieldId = [];
|
|
if (method_exists($entity, 'getCustomFieldValues')) {
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$fieldId = $cfv->getCustomField()?->getId();
|
|
if (null !== $fieldId) {
|
|
$currentCfvsByFieldId[$fieldId] = $cfv;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($snapshotCfvs as $cfvData) {
|
|
$fieldId = $cfvData['fieldId'] ?? null;
|
|
if (!$fieldId) {
|
|
continue;
|
|
}
|
|
|
|
// Try to find the current CFV by fieldId (resilient to ID changes after sync)
|
|
$cfv = $currentCfvsByFieldId[$fieldId] ?? null;
|
|
|
|
// Fallback: try by original ID if fieldId lookup failed
|
|
if (null === $cfv && !empty($cfvData['id'])) {
|
|
$cfv = $this->customFieldValues->find($cfvData['id']);
|
|
}
|
|
|
|
if (null !== $cfv) {
|
|
$cfv->setValue($cfvData['value'] ?? null);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a complete snapshot of the entity in its current state (after restore).
|
|
* Must be consistent with the snapshots built by the audit subscribers,
|
|
* so that a future restore from a "restore" version works correctly.
|
|
*/
|
|
private function buildCurrentSnapshot(string $entityType, object $entity): array
|
|
{
|
|
$snapshot = ['id' => $entity->getId()];
|
|
|
|
// Base fields
|
|
$baseFields = self::BASE_FIELDS[$entityType] ?? [];
|
|
foreach ($baseFields as $field) {
|
|
$getter = 'get'.ucfirst($field);
|
|
if (method_exists($entity, $getter)) {
|
|
$snapshot[$field] = $entity->{$getter}();
|
|
}
|
|
}
|
|
|
|
// Version
|
|
if (method_exists($entity, 'getVersion')) {
|
|
$snapshot['version'] = $entity->getVersion();
|
|
}
|
|
|
|
// Constructeurs
|
|
if (method_exists($entity, 'getConstructeurs')) {
|
|
$snapshot['constructeurIds'] = [];
|
|
foreach ($entity->getConstructeurs() as $c) {
|
|
$snapshot['constructeurIds'][] = ['id' => $c->getId(), 'name' => $c->getName()];
|
|
}
|
|
}
|
|
|
|
// Type (ModelType reference)
|
|
$typeGetters = ['composant' => 'getTypeComposant', 'piece' => 'getTypePiece', 'product' => 'getTypeProduct'];
|
|
$typeKeys = ['composant' => 'typeComposant', 'piece' => 'typePiece', 'product' => 'typeProduct'];
|
|
if (isset($typeGetters[$entityType]) && method_exists($entity, $typeGetters[$entityType])) {
|
|
$type = $entity->{$typeGetters[$entityType]}();
|
|
$snapshot[$typeKeys[$entityType]] = $type ? ['id' => $type->getId(), 'name' => $type->getName(), 'code' => $type->getCode()] : null;
|
|
}
|
|
|
|
// Machine: site
|
|
if ('machine' === $entityType && method_exists($entity, 'getSite')) {
|
|
$site = $entity->getSite();
|
|
$snapshot['site'] = $site ? ['id' => $site->getId(), 'name' => $site->getName()] : null;
|
|
}
|
|
|
|
// Composant/Piece: product
|
|
if (in_array($entityType, ['composant', 'piece'], true) && method_exists($entity, 'getProduct')) {
|
|
$product = $entity->getProduct();
|
|
$snapshot['product'] = $product ? ['id' => $product->getId(), 'name' => $product->getName()] : null;
|
|
}
|
|
|
|
// Slots
|
|
if ('composant' === $entityType) {
|
|
$snapshot['pieceSlots'] = [];
|
|
foreach ($entity->getPieceSlots() as $slot) {
|
|
$snapshot['pieceSlots'][] = [
|
|
'id' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
|
|
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
|
'quantity' => $slot->getQuantity(), 'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
$snapshot['subcomponentSlots'] = [];
|
|
foreach ($entity->getSubcomponentSlots() as $slot) {
|
|
$snapshot['subcomponentSlots'][] = [
|
|
'id' => $slot->getId(), 'alias' => $slot->getAlias(), 'familyCode' => $slot->getFamilyCode(),
|
|
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
|
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
|
'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
$snapshot['productSlots'] = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$snapshot['productSlots'][] = [
|
|
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
}
|
|
|
|
if ('piece' === $entityType) {
|
|
$snapshot['productSlots'] = [];
|
|
foreach ($entity->getProductSlots() as $slot) {
|
|
$snapshot['productSlots'][] = [
|
|
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
];
|
|
}
|
|
}
|
|
|
|
// Custom field values
|
|
if (method_exists($entity, 'getCustomFieldValues')) {
|
|
$snapshot['customFieldValues'] = [];
|
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
|
$snapshot['customFieldValues'][] = [
|
|
'id' => $cfv->getId(), 'fieldName' => $cfv->getCustomField()?->getName(),
|
|
'fieldId' => $cfv->getCustomField()?->getId(), 'value' => $cfv->getValue(),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* Resolve the current actor profile ID from the session.
|
|
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
|
*/
|
|
private function resolveActorProfileId(): ?string
|
|
{
|
|
try {
|
|
$session = $this->requestStack->getSession();
|
|
$profileId = $session->get('profileId');
|
|
if ($profileId) {
|
|
return (string) $profileId;
|
|
}
|
|
} catch (\Throwable) {
|
|
// No session available (CLI context, etc.)
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Service/EntityVersionService.php
|
|
git commit -m "feat(versioning) : add EntityVersionService with restore logic"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7b: skipAudit flag on entities + subscriber check
|
|
|
|
The `restore()` method creates its own AuditLog with `action = "restore"`. The audit subscribers must skip entities flagged with `skipAudit = true` to avoid a duplicate `update` AuditLog.
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Machine.php`, `src/Entity/Composant.php`, `src/Entity/Piece.php`, `src/Entity/Product.php`
|
|
- Modify: `src/EventSubscriber/AbstractAuditSubscriber.php`
|
|
|
|
- [ ] **Step 1: Add skipAudit flag to each entity**
|
|
|
|
Add to Machine, Composant, Piece, Product (transient property, NOT mapped to DB):
|
|
|
|
```php
|
|
/**
|
|
* Transient flag — when true, audit subscribers skip this entity.
|
|
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
|
|
*/
|
|
private bool $skipAudit = false;
|
|
|
|
public function getSkipAudit(): bool
|
|
{
|
|
return $this->skipAudit;
|
|
}
|
|
|
|
public function setSkipAudit(bool $skipAudit): static
|
|
{
|
|
$this->skipAudit = $skipAudit;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add skipAudit check in AbstractAuditSubscriber**
|
|
|
|
In `onFlush()` method, add an early return that scans all scheduled entities for the `skipAudit` flag. This covers ALL paths (simple, complex, collections, CFV changes) and avoids any duplicate AuditLogs:
|
|
|
|
```php
|
|
public function onFlush(OnFlushEventArgs $args): void
|
|
{
|
|
$em = $args->getObjectManager();
|
|
if (!$em instanceof EntityManagerInterface) {
|
|
return;
|
|
}
|
|
|
|
$uow = $em->getUnitOfWork();
|
|
|
|
// If any tracked entity has skipAudit=true, skip the entire subscriber.
|
|
// This is set by EntityVersionService::restore() to avoid duplicate audit logs.
|
|
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
|
if ($this->supports($entity) && method_exists($entity, 'getSkipAudit') && $entity->getSkipAudit()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$actorProfileId = $this->resolveActorProfileId();
|
|
$entityType = $this->entityType();
|
|
|
|
if ($this->hasCollectionTracking()) {
|
|
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
|
|
} else {
|
|
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
|
|
}
|
|
}
|
|
```
|
|
|
|
This replaces the existing `onFlush()` method. The check is at the top level so it covers entity updates, collection changes, and custom field value changes — all paths that `onFlushComplex` processes.
|
|
|
|
- [ ] **Step 3: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Machine.php src/Entity/Composant.php src/Entity/Piece.php src/Entity/Product.php src/EventSubscriber/AbstractAuditSubscriber.php
|
|
git commit -m "feat(versioning) : add skipAudit flag for restore-originated flushes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: EntityVersionController — REST endpoints
|
|
|
|
**Files:**
|
|
- Create: `src/Controller/EntityVersionController.php`
|
|
|
|
- [ ] **Step 1: Create the controller**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Repository\ComposantRepository;
|
|
use App\Repository\MachineRepository;
|
|
use App\Repository\PieceRepository;
|
|
use App\Repository\ProductRepository;
|
|
use App\Service\EntityVersionService;
|
|
use Doctrine\ORM\EntityRepository;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
final class EntityVersionController extends AbstractController
|
|
{
|
|
/** @var array<string, array{repo: EntityRepository<object>, label: string}> */
|
|
private readonly array $entityConfig;
|
|
|
|
public function __construct(
|
|
MachineRepository $machines,
|
|
PieceRepository $pieces,
|
|
ComposantRepository $composants,
|
|
ProductRepository $products,
|
|
private readonly EntityVersionService $versionService,
|
|
) {
|
|
$this->entityConfig = [
|
|
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
|
|
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
|
|
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
|
|
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
|
|
];
|
|
}
|
|
|
|
// ── Versions list ───────────────────────────────────────────────
|
|
|
|
#[Route('/api/machines/{id}/versions', name: 'api_machine_versions', methods: ['GET'])]
|
|
public function machineVersions(string $id): JsonResponse
|
|
{
|
|
return $this->listVersions('machine', $id);
|
|
}
|
|
|
|
#[Route('/api/composants/{id}/versions', name: 'api_composant_versions', methods: ['GET'])]
|
|
public function composantVersions(string $id): JsonResponse
|
|
{
|
|
return $this->listVersions('composant', $id);
|
|
}
|
|
|
|
#[Route('/api/pieces/{id}/versions', name: 'api_piece_versions', methods: ['GET'])]
|
|
public function pieceVersions(string $id): JsonResponse
|
|
{
|
|
return $this->listVersions('piece', $id);
|
|
}
|
|
|
|
#[Route('/api/products/{id}/versions', name: 'api_product_versions', methods: ['GET'])]
|
|
public function productVersions(string $id): JsonResponse
|
|
{
|
|
return $this->listVersions('product', $id);
|
|
}
|
|
|
|
// ── Preview ─────────────────────────────────────────────────────
|
|
|
|
#[Route('/api/machines/{id}/versions/{version}/preview', name: 'api_machine_version_preview', methods: ['GET'])]
|
|
public function machineVersionPreview(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->preview('machine', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/composants/{id}/versions/{version}/preview', name: 'api_composant_version_preview', methods: ['GET'])]
|
|
public function composantVersionPreview(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->preview('composant', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/pieces/{id}/versions/{version}/preview', name: 'api_piece_version_preview', methods: ['GET'])]
|
|
public function pieceVersionPreview(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->preview('piece', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/products/{id}/versions/{version}/preview', name: 'api_product_version_preview', methods: ['GET'])]
|
|
public function productVersionPreview(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->preview('product', $id, $version);
|
|
}
|
|
|
|
// ── Restore ─────────────────────────────────────────────────────
|
|
|
|
#[Route('/api/machines/{id}/versions/{version}/restore', name: 'api_machine_version_restore', methods: ['POST'])]
|
|
public function machineVersionRestore(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->restoreVersion('machine', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/composants/{id}/versions/{version}/restore', name: 'api_composant_version_restore', methods: ['POST'])]
|
|
public function composantVersionRestore(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->restoreVersion('composant', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/pieces/{id}/versions/{version}/restore', name: 'api_piece_version_restore', methods: ['POST'])]
|
|
public function pieceVersionRestore(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->restoreVersion('piece', $id, $version);
|
|
}
|
|
|
|
#[Route('/api/products/{id}/versions/{version}/restore', name: 'api_product_version_restore', methods: ['POST'])]
|
|
public function productVersionRestore(string $id, int $version): JsonResponse
|
|
{
|
|
return $this->restoreVersion('product', $id, $version);
|
|
}
|
|
|
|
// ── Private helpers ─────────────────────────────────────────────
|
|
|
|
private function listVersions(string $entityType, string $entityId): JsonResponse
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
|
|
|
$config = $this->entityConfig[$entityType];
|
|
if (!$config['repo']->find($entityId)) {
|
|
return new JsonResponse(['message' => $config['label']], Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
return new JsonResponse($this->versionService->getVersions($entityType, $entityId));
|
|
}
|
|
|
|
private function preview(string $entityType, string $entityId, int $version): JsonResponse
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
|
|
|
try {
|
|
$result = $this->versionService->getRestorePreview($entityType, $entityId, $version);
|
|
|
|
return new JsonResponse($result);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
|
}
|
|
}
|
|
|
|
private function restoreVersion(string $entityType, string $entityId, int $version): JsonResponse
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
|
|
|
try {
|
|
$result = $this->versionService->restore($entityType, $entityId, $version);
|
|
|
|
return new JsonResponse($result);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run php-cs-fixer**
|
|
|
|
Run: `make php-cs-fixer-allow-risky`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Controller/EntityVersionController.php
|
|
git commit -m "feat(versioning) : add EntityVersionController with list, preview and restore endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Backend tests
|
|
|
|
**Files:**
|
|
- Create: `tests/Api/Controller/EntityVersionTest.php`
|
|
|
|
- [ ] **Step 1: Create the test file**
|
|
|
|
```php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Api\Controller;
|
|
|
|
use App\Tests\AbstractApiTestCase;
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class EntityVersionTest extends AbstractApiTestCase
|
|
{
|
|
// ── Versions list ───────────────────────────────────────────────
|
|
|
|
public function testMachineVersionsAfterCreateAndUpdate(): void
|
|
{
|
|
$machine = $this->createMachine('Machine V');
|
|
|
|
$gClient = $this->createGestionnaireClient();
|
|
$gClient->request('PATCH', self::iri('machines', $machine->getId()), [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['name' => 'Machine V Updated'],
|
|
]);
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$vClient = $this->createViewerClient();
|
|
$vClient->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$data = $vClient->getResponse()->toArray();
|
|
$this->assertArrayHasKey('items', $data);
|
|
$this->assertArrayHasKey('total', $data);
|
|
$this->assertGreaterThanOrEqual(1, $data['total']);
|
|
|
|
$firstItem = $data['items'][0];
|
|
$this->assertArrayHasKey('version', $firstItem);
|
|
$this->assertArrayHasKey('action', $firstItem);
|
|
$this->assertArrayHasKey('createdAt', $firstItem);
|
|
}
|
|
|
|
public function testComposantVersionsList(): void
|
|
{
|
|
$composant = $this->createComposant('Composant V');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', sprintf('/api/composants/%s/versions', $composant->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$data = $client->getResponse()->toArray();
|
|
$this->assertArrayHasKey('items', $data);
|
|
}
|
|
|
|
public function testPieceVersionsList(): void
|
|
{
|
|
$piece = $this->createPiece('Piece V');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', sprintf('/api/pieces/%s/versions', $piece->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
|
|
public function testProductVersionsList(): void
|
|
{
|
|
$product = $this->createProduct('Product V');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', sprintf('/api/products/%s/versions', $product->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
}
|
|
|
|
public function testVersionsNotFound(): void
|
|
{
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', '/api/machines/nonexistent-id/versions');
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function testVersionsUnauthenticated(): void
|
|
{
|
|
$machine = $this->createMachine('Machine V');
|
|
|
|
$client = $this->createUnauthenticatedClient();
|
|
$client->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
|
|
$this->assertResponseStatusCodeSame(401);
|
|
}
|
|
|
|
// ── Preview ─────────────────────────────────────────────────────
|
|
|
|
public function testPreviewRequiresGestionnaire(): void
|
|
{
|
|
$machine = $this->createMachine('Machine P');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('GET', sprintf('/api/machines/%s/versions/1/preview', $machine->getId()));
|
|
$this->assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testPreviewReturnsRestoreInfo(): void
|
|
{
|
|
$composant = $this->createComposant('Composant P');
|
|
|
|
// Update to create version 2
|
|
$gClient = $this->createGestionnaireClient();
|
|
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['name' => 'Composant P Updated'],
|
|
]);
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
// Preview restore to version 1
|
|
$gClient2 = $this->createGestionnaireClient();
|
|
$gClient2->request('GET', sprintf('/api/composants/%s/versions/1/preview', $composant->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$data = $gClient2->getResponse()->toArray();
|
|
$this->assertArrayHasKey('version', $data);
|
|
$this->assertArrayHasKey('restoreMode', $data);
|
|
$this->assertArrayHasKey('diff', $data);
|
|
$this->assertArrayHasKey('warnings', $data);
|
|
$this->assertEquals(1, $data['version']);
|
|
$this->assertEquals('full', $data['restoreMode']);
|
|
}
|
|
|
|
// ── Restore ─────────────────────────────────────────────────────
|
|
|
|
public function testRestoreRequiresGestionnaire(): void
|
|
{
|
|
$machine = $this->createMachine('Machine R');
|
|
|
|
$client = $this->createViewerClient();
|
|
$client->request('POST', sprintf('/api/machines/%s/versions/1/restore', $machine->getId()));
|
|
$this->assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testRestoreCreatesNewVersion(): void
|
|
{
|
|
$composant = $this->createComposant('Original Name');
|
|
|
|
// Update
|
|
$gClient = $this->createGestionnaireClient();
|
|
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
|
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
|
'json' => ['name' => 'Updated Name'],
|
|
]);
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
// Restore to version 1
|
|
$gClient2 = $this->createGestionnaireClient();
|
|
$gClient2->request('POST', sprintf('/api/composants/%s/versions/1/restore', $composant->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
|
|
$data = $gClient2->getResponse()->toArray();
|
|
$this->assertTrue($data['success']);
|
|
$this->assertEquals(1, $data['restoredFromVersion']);
|
|
$this->assertGreaterThan(2, $data['newVersion']);
|
|
|
|
// Verify the name was restored
|
|
$vClient = $this->createViewerClient();
|
|
$vClient->request('GET', self::iri('composants', $composant->getId()));
|
|
$this->assertResponseIsSuccessful();
|
|
$entityData = $vClient->getResponse()->toArray();
|
|
$this->assertEquals('Original Name', $entityData['name']);
|
|
}
|
|
|
|
public function testRestoreVersionNotFound(): void
|
|
{
|
|
$composant = $this->createComposant('Composant NF');
|
|
|
|
$client = $this->createGestionnaireClient();
|
|
$client->request('POST', sprintf('/api/composants/%s/versions/999/restore', $composant->getId()));
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
Run: `make test FILES=tests/Api/Controller/EntityVersionTest.php`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 3: Run full test suite**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass including existing tests.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/Api/Controller/EntityVersionTest.php
|
|
git commit -m "test(versioning) : add EntityVersionTest for list, preview and restore"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9b: Frontend — add `restore` action label to historyDisplayUtils
|
|
|
|
**Files:**
|
|
- Modify: `frontend/app/shared/utils/historyDisplayUtils.ts`
|
|
|
|
- [ ] **Step 1: Add `restore` case to `historyActionLabel`**
|
|
|
|
In `historyDisplayUtils.ts`, update `historyActionLabel`:
|
|
|
|
```typescript
|
|
export const historyActionLabel = (action: string): string => {
|
|
if (action === 'create') return 'Création'
|
|
if (action === 'delete') return 'Suppression'
|
|
if (action === 'restore') return 'Restauration'
|
|
return 'Modification'
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit in frontend repo**
|
|
|
|
```bash
|
|
cd frontend
|
|
git add app/shared/utils/historyDisplayUtils.ts
|
|
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
|
|
cd ..
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend — useEntityVersions composable
|
|
|
|
**Files:**
|
|
- Create: `frontend/app/composables/useEntityVersions.ts`
|
|
|
|
- [ ] **Step 1: Create the composable**
|
|
|
|
```typescript
|
|
import { ref, toValue } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import type { MaybeRef } from 'vue'
|
|
|
|
export interface VersionEntry {
|
|
version: number
|
|
action: 'create' | 'update' | 'restore' | string
|
|
createdAt: string
|
|
actor: { id: string; label: string } | null
|
|
diff: Record<string, { from: unknown; to: unknown }> | null
|
|
}
|
|
|
|
export interface RestorePreview {
|
|
version: number
|
|
restoreMode: 'full' | 'partial'
|
|
diff: Record<string, { current: unknown; restored: unknown }>
|
|
warnings: Array<{
|
|
field: string
|
|
message: string
|
|
missingEntityId: string | null
|
|
missingEntityName: string | null
|
|
}>
|
|
snapshot: Record<string, unknown>
|
|
}
|
|
|
|
export interface RestoreResult {
|
|
success: boolean
|
|
newVersion: number
|
|
restoredFromVersion: number
|
|
restoreMode: 'full' | 'partial'
|
|
warnings: RestorePreview['warnings']
|
|
}
|
|
|
|
const ENTITY_ENDPOINTS: Record<string, string> = {
|
|
machine: '/machines',
|
|
composant: '/composants',
|
|
piece: '/pieces',
|
|
product: '/products',
|
|
}
|
|
|
|
interface Deps {
|
|
entityType: MaybeRef<string>
|
|
entityId: MaybeRef<string>
|
|
}
|
|
|
|
export function useEntityVersions(deps: Deps) {
|
|
const { get, post } = useApi()
|
|
|
|
const versions = ref<VersionEntry[]>([])
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
const getPath = () => {
|
|
const type = toValue(deps.entityType)
|
|
const id = toValue(deps.entityId)
|
|
const base = ENTITY_ENDPOINTS[type]
|
|
return `${base}/${id}`
|
|
}
|
|
|
|
const fetchVersions = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const result = await get(`${getPath()}/versions`)
|
|
if (!result.success) {
|
|
error.value = result.error ?? 'Impossible de charger les versions.'
|
|
versions.value = []
|
|
return
|
|
}
|
|
versions.value = result.data?.items ?? []
|
|
} catch (err: any) {
|
|
error.value = err?.message ?? 'Erreur inconnue'
|
|
versions.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
|
|
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
|
|
if (!result.success || !result.data) {
|
|
return null
|
|
}
|
|
return result.data
|
|
}
|
|
|
|
const restore = async (version: number): Promise<RestoreResult | null> => {
|
|
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
|
|
if (!result.success || !result.data) {
|
|
return null
|
|
}
|
|
return result.data
|
|
}
|
|
|
|
return { versions, loading, error, fetchVersions, fetchPreview, restore }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run lint**
|
|
|
|
Run (in `frontend/`): `npm run lint:fix`
|
|
|
|
- [ ] **Step 3: Run typecheck**
|
|
|
|
Run (in `frontend/`): `npx nuxi typecheck`
|
|
|
|
- [ ] **Step 4: Commit in frontend repo**
|
|
|
|
```bash
|
|
cd frontend
|
|
git add app/composables/useEntityVersions.ts
|
|
git commit -m "feat(versioning) : add useEntityVersions composable"
|
|
cd ..
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Frontend — VersionRestoreModal component
|
|
|
|
**Files:**
|
|
- Create: `frontend/app/components/common/VersionRestoreModal.vue`
|
|
|
|
- [ ] **Step 1: Create the modal component**
|
|
|
|
```vue
|
|
<template>
|
|
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
|
<div class="modal-box max-w-lg">
|
|
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
|
|
|
<div v-if="!preview" class="flex justify-center py-8">
|
|
<span class="loading loading-spinner loading-md" />
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div class="mt-4 space-y-4">
|
|
<!-- Restore mode -->
|
|
<div
|
|
class="alert text-sm"
|
|
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
|
>
|
|
<span v-if="preview.restoreMode === 'full'">
|
|
Restauration complète — le squelette est identique.
|
|
</span>
|
|
<span v-else>
|
|
Restauration partielle — le squelette a changé. Seuls les champs de base seront restaurés.
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Diff -->
|
|
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
|
<h4 class="text-sm font-semibold">Changements à appliquer</h4>
|
|
<ul class="space-y-1 text-sm">
|
|
<li
|
|
v-for="(change, field) in preview.diff"
|
|
:key="field"
|
|
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
|
>
|
|
<span class="font-medium text-base-content">{{ fieldLabels[field] || field }}</span>
|
|
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
|
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Warnings -->
|
|
<div v-if="preview.warnings.length" class="space-y-1">
|
|
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
|
<ul class="space-y-1">
|
|
<li
|
|
v-for="(warning, i) in preview.warnings"
|
|
:key="i"
|
|
class="alert alert-warning text-xs py-2"
|
|
>
|
|
{{ warning.message }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
|
Annuler
|
|
</button>
|
|
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
|
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
|
Confirmer la restauration
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
|
<button type="button">close</button>
|
|
</form>
|
|
</dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { RestorePreview } from '~/composables/useEntityVersions'
|
|
|
|
defineProps<{
|
|
visible: boolean
|
|
preview: RestorePreview | null
|
|
restoring: boolean
|
|
fieldLabels: Record<string, string>
|
|
}>()
|
|
|
|
defineEmits<{
|
|
close: []
|
|
confirm: []
|
|
}>()
|
|
|
|
const formatValue = (value: unknown): string => {
|
|
if (value === null || value === undefined) return '—'
|
|
if (Array.isArray(value)) {
|
|
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
|
}
|
|
if (typeof value === 'object') return JSON.stringify(value)
|
|
return String(value)
|
|
}
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Step 2: Run lint**
|
|
|
|
Run (in `frontend/`): `npm run lint:fix`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd frontend
|
|
git add app/components/common/VersionRestoreModal.vue
|
|
git commit -m "feat(versioning) : add VersionRestoreModal component"
|
|
cd ..
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Frontend — EntityVersionList component
|
|
|
|
**Files:**
|
|
- Create: `frontend/app/components/common/EntityVersionList.vue`
|
|
|
|
- [ ] **Step 1: Create the version list component**
|
|
|
|
```vue
|
|
<template>
|
|
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
<header class="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 class="font-semibold text-base-content">Versions</h2>
|
|
<p class="text-xs text-base-content/70">
|
|
Historique des versions avec possibilité de restauration.
|
|
</p>
|
|
</div>
|
|
<span v-if="versions.length" class="badge badge-outline">
|
|
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
|
|
</span>
|
|
</header>
|
|
|
|
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
|
Chargement des versions…
|
|
</div>
|
|
|
|
<div v-else-if="error" class="alert alert-warning">
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
|
|
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
|
Aucune version enregistrée.
|
|
</p>
|
|
|
|
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
|
<li
|
|
v-for="entry in versions"
|
|
:key="entry.version"
|
|
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
|
<span
|
|
v-if="entry.version === currentVersion"
|
|
class="badge badge-primary badge-sm"
|
|
>
|
|
actuelle
|
|
</span>
|
|
<span
|
|
v-if="entry.action === 'restore'"
|
|
class="badge badge-warning badge-sm"
|
|
>
|
|
restauration
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2 mt-0.5 text-xs text-base-content/60">
|
|
<span>{{ actionLabel(entry.action) }}</span>
|
|
<span>·</span>
|
|
<span>{{ formatDate(entry.createdAt) }}</span>
|
|
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="canRestore && entry.version !== currentVersion"
|
|
class="btn btn-ghost btn-xs"
|
|
:disabled="restoring"
|
|
@click="handleRestore(entry.version)"
|
|
>
|
|
Restaurer
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<VersionRestoreModal
|
|
:visible="modalVisible"
|
|
:preview="previewData"
|
|
:restoring="restoring"
|
|
:field-labels="fieldLabels"
|
|
@close="modalVisible = false"
|
|
@confirm="confirmRestore"
|
|
/>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
|
import { usePermissions } from '~/composables/usePermissions'
|
|
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
|
import VersionRestoreModal from './VersionRestoreModal.vue'
|
|
|
|
const props = defineProps<{
|
|
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
|
entityId: string
|
|
fieldLabels: Record<string, string>
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
restored: []
|
|
}>()
|
|
|
|
const { canEdit } = usePermissions()
|
|
const canRestore = computed(() => canEdit.value)
|
|
|
|
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
|
entityType: props.entityType,
|
|
entityId: props.entityId,
|
|
})
|
|
|
|
const currentVersion = computed(() => {
|
|
if (versions.value.length === 0) return null
|
|
return versions.value[0]?.version ?? null
|
|
})
|
|
|
|
const modalVisible = ref(false)
|
|
const previewData = ref<RestorePreview | null>(null)
|
|
const restoring = ref(false)
|
|
const targetVersion = ref<number | null>(null)
|
|
|
|
const actionLabel = (action: string) => historyActionLabel(action)
|
|
const formatDate = (date: string) => formatHistoryDate(date)
|
|
|
|
const handleRestore = async (version: number) => {
|
|
targetVersion.value = version
|
|
previewData.value = null
|
|
modalVisible.value = true
|
|
previewData.value = await fetchPreview(version)
|
|
}
|
|
|
|
const confirmRestore = async () => {
|
|
if (!targetVersion.value) return
|
|
restoring.value = true
|
|
const result = await restore(targetVersion.value)
|
|
restoring.value = false
|
|
if (result?.success) {
|
|
modalVisible.value = false
|
|
await fetchVersions()
|
|
emit('restored')
|
|
} else {
|
|
error.value = 'La restauration a échoué.'
|
|
modalVisible.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchVersions()
|
|
})
|
|
</script>
|
|
```
|
|
|
|
- [ ] **Step 2: Run lint**
|
|
|
|
Run (in `frontend/`): `npm run lint:fix`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd frontend
|
|
git add app/components/common/EntityVersionList.vue
|
|
git commit -m "feat(versioning) : add EntityVersionList component with restore flow"
|
|
cd ..
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Frontend — Integrate EntityVersionList into detail pages
|
|
|
|
**Files:**
|
|
- Modify: `frontend/app/pages/machine/[id].vue`
|
|
- Modify: `frontend/app/pages/component/[id]/edit.vue`
|
|
- Modify: `frontend/app/pages/piece/[id].vue`
|
|
- Modify: `frontend/app/pages/product/[id]/edit.vue`
|
|
|
|
- [ ] **Step 1: Add EntityVersionList to machine/[id].vue**
|
|
|
|
In `machine/[id].vue`, add after the `EntityHistorySection` block (after line 147) and before the Comments section:
|
|
|
|
```vue
|
|
<!-- Versions -->
|
|
<EntityVersionList
|
|
entity-type="machine"
|
|
:entity-id="String(machineId)"
|
|
:field-labels="historyFieldLabels"
|
|
@restored="d.loadMachineData()"
|
|
/>
|
|
```
|
|
|
|
Add the import at the top of `<script setup>`:
|
|
|
|
```js
|
|
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
|
```
|
|
|
|
- [ ] **Step 2: Add EntityVersionList to component/[id]/edit.vue**
|
|
|
|
In `component/[id]/edit.vue`, add after the `EntityHistorySection` block (after line 317) and before the save buttons:
|
|
|
|
```vue
|
|
<EntityVersionList
|
|
entity-type="composant"
|
|
:entity-id="String(route.params.id)"
|
|
:field-labels="historyFieldLabels"
|
|
@restored="fetchComponent()"
|
|
/>
|
|
```
|
|
|
|
Add the import:
|
|
|
|
```js
|
|
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
|
```
|
|
|
|
- [ ] **Step 3: Add EntityVersionList to piece/[id].vue**
|
|
|
|
In `piece/[id].vue`, add after the `EntityHistorySection` block (after line 330) and before the save buttons:
|
|
|
|
```vue
|
|
<EntityVersionList
|
|
entity-type="piece"
|
|
:entity-id="String(route.params.id)"
|
|
:field-labels="historyFieldLabels"
|
|
@restored="fetchPiece()"
|
|
/>
|
|
```
|
|
|
|
Add the import:
|
|
|
|
```js
|
|
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
|
```
|
|
|
|
- [ ] **Step 4: Add EntityVersionList to product/[id]/edit.vue**
|
|
|
|
In `product/[id]/edit.vue`, add after the `EntityHistorySection` block and before the save buttons:
|
|
|
|
```vue
|
|
<EntityVersionList
|
|
entity-type="product"
|
|
:entity-id="String(route.params.id)"
|
|
:field-labels="historyFieldLabels"
|
|
@restored="loadProduct()"
|
|
/>
|
|
```
|
|
|
|
Where `loadProduct` is the existing function that calls `getProduct(id)` and populates the form. If no such function is exposed, extract the onMounted data-loading logic into a named function that can be called from `@restored`.
|
|
|
|
Add the import:
|
|
|
|
```js
|
|
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
|
```
|
|
|
|
- [ ] **Step 5: Run lint**
|
|
|
|
Run (in `frontend/`): `npm run lint:fix`
|
|
|
|
- [ ] **Step 6: Run typecheck**
|
|
|
|
Run (in `frontend/`): `npx nuxi typecheck`
|
|
|
|
- [ ] **Step 7: Commit in frontend repo**
|
|
|
|
```bash
|
|
cd frontend
|
|
git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue
|
|
git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages"
|
|
cd ..
|
|
```
|
|
|
|
- [ ] **Step 8: Update submodule pointer in main repo**
|
|
|
|
```bash
|
|
git add frontend
|
|
git commit -m "chore(submodule) : update frontend pointer (entity versioning)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Final verification
|
|
|
|
- [ ] **Step 1: Run backend tests**
|
|
|
|
Run: `make test`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 2: Run frontend lint + typecheck**
|
|
|
|
Run (in `frontend/`): `npm run lint:fix && npx nuxi typecheck`
|
|
Expected: 0 errors.
|
|
|
|
- [ ] **Step 3: Manual smoke test**
|
|
|
|
1. Create a composant → check versions list shows v1
|
|
2. Update composant name → versions list shows v2
|
|
3. Click "Restaurer" on v1 → preview shows diff + "full" mode
|
|
4. Confirm → name reverts, v3 created
|
|
5. Repeat for piece, product, machine
|
|
|
|
- [ ] **Step 4: Update CLAUDE.md if needed**
|
|
|
|
Add `EntityVersionController` to the Custom Controllers section and note the version auto-increment behavior.
|