Compare commits
4 Commits
162c6ece71
...
03c2451990
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c2451990 | ||
|
|
3f6ce153bb | ||
|
|
d568961eb3 | ||
|
|
9299a46c8b |
Submodule Inventory_frontend updated: 232436be21...c82c21c0cd
@@ -64,3 +64,8 @@ when@test:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ReferenceAutoGenerator:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
1074
docs/superpowers/plans/2026-03-26-machine-single-save.md
Normal file
1074
docs/superpowers/plans/2026-03-26-machine-single-save.md
Normal file
File diff suppressed because it is too large
Load Diff
117
docs/superpowers/specs/2026-03-26-machine-single-save-design.md
Normal file
117
docs/superpowers/specs/2026-03-26-machine-single-save-design.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Machine : Bouton Save Unique + Versioning des Liens
|
||||
|
||||
**Date :** 2026-03-26
|
||||
**Statut :** Approuvé
|
||||
|
||||
## Contexte
|
||||
|
||||
La page machine utilise actuellement un auto-save au blur pour chaque champ (info, custom fields, constructeurs). Les pages composant/pièce/produit utilisent un bouton unique "Enregistrer les modifications" en bas du formulaire. L'objectif est d'aligner la page machine sur ce pattern.
|
||||
|
||||
De plus, les ajouts/suppressions de liens composant/pièce/produit sur une machine ne sont pas tracés dans le versioning. Ils doivent l'être.
|
||||
|
||||
## Volet 1 : Bouton Save Unique
|
||||
|
||||
### Comportement cible
|
||||
|
||||
- En mode édition, tous les champs (info machine, custom field values, custom field definitions, constructeurs) sont modifiés localement sans appel API.
|
||||
- Un bouton "Enregistrer les modifications" en bas du formulaire sauvegarde tout d'un coup.
|
||||
- Un bouton "Annuler" réinitialise l'état local et sort du mode édition.
|
||||
- Les documents restent en upload/suppression immédiate (inchangé).
|
||||
- Les ajouts/suppressions de liens composant/pièce/produit restent immédiats via modales (inchangé).
|
||||
|
||||
### Changements frontend
|
||||
|
||||
#### MachineInfoCard.vue
|
||||
- Supprimer les `@blur` → `$emit('blur-field')` sur les inputs (nom, référence)
|
||||
- Supprimer le `@change` qui émet `blur-field` sur le select site
|
||||
- Supprimer les `@blur` → `$emit('update-custom-field', field)` sur tous les champs custom
|
||||
- Conserver `@input` / `@update:*` / `set-custom-field-value` pour la mise à jour de l'état local
|
||||
- Le `MachineCustomFieldDefEditor` perd son bouton save propre : l'état est collecté au submit global
|
||||
|
||||
#### machine/[id].vue
|
||||
- Supprimer le handler `@blur-field`
|
||||
- Supprimer le handler `@update-custom-field`
|
||||
- `@update:constructeur-ids` met à jour l'état local sans save
|
||||
- Ajouter le bloc boutons en bas (pattern identique à component/[id]/index.vue) :
|
||||
- "Annuler" (btn-ghost) → `cancelEdition()` : réinitialise depuis `machine.value` + sort du mode édition
|
||||
- "Enregistrer les modifications" (btn-primary, disabled si `!canSubmit`) → `submitEdition()`
|
||||
|
||||
#### useMachineDetailData.ts
|
||||
- Exposer `saving` ref
|
||||
- Exposer `submitEdition()` :
|
||||
1. `updateMachineInfo()` — PATCH machine (nom, ref, site, constructeurs)
|
||||
2. Batch save custom field values (tous les `visibleMachineCustomFields` avec valeur)
|
||||
3. Save custom field definitions si modifiées (`fieldDefs.saveDefinitions()`)
|
||||
4. `loadMachineData()` pour recharger
|
||||
5. Sortie du mode édition + toast succès
|
||||
- Exposer `cancelEdition()` :
|
||||
1. `initMachineFields()` — réinitialise nom, ref, site, constructeurs depuis `machine.value`
|
||||
2. `syncMachineCustomFields()` — réinitialise les custom fields
|
||||
3. Sort du mode édition
|
||||
|
||||
#### useMachineDetailUpdates.ts
|
||||
- `handleMachineConstructeurChange` ne déclenche plus `updateMachineInfo()`, met juste à jour le ref local
|
||||
|
||||
#### useMachineDetailCustomFields.ts
|
||||
- `updateMachineCustomField` n'est plus appelé au blur — sera appelé en batch par `submitEdition()`
|
||||
- Ajouter méthode `saveAllMachineCustomFields()` qui itère sur les champs visibles et sauvegarde ceux avec valeur
|
||||
|
||||
### Validation (`canSubmit`)
|
||||
- Machine existe
|
||||
- Nom non vide
|
||||
- Pas en cours de sauvegarde (`!saving.value`)
|
||||
- `canEdit` est true
|
||||
|
||||
## Volet 2 : Versioning des Liens Machine
|
||||
|
||||
### Comportement cible
|
||||
|
||||
Quand un composant, pièce ou produit est ajouté ou supprimé d'une machine, cela doit :
|
||||
1. Incrémenter la `version` de la Machine
|
||||
2. Créer une entrée `AuditLog` avec diff et snapshot
|
||||
|
||||
### Changements backend
|
||||
|
||||
#### MachineAuditSubscriber — enrichir le snapshot
|
||||
Ajouter au snapshot machine les liens :
|
||||
```php
|
||||
'componentLinks' => array_map(fn($link) => [
|
||||
'id' => $link->getId(),
|
||||
'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
], $entity->getComponentLinks()->toArray()),
|
||||
'pieceLinks' => [...],
|
||||
'productLinks' => [...],
|
||||
```
|
||||
|
||||
#### Nouveau subscriber ou service : MachineLinkAuditService
|
||||
Écouter les événements Doctrine `postPersist` et `postRemove` sur les 3 entités link.
|
||||
Quand un lien est créé/supprimé :
|
||||
1. Récupérer la Machine parente
|
||||
2. Incrémenter `$machine->incrementVersion()`
|
||||
3. Créer un `AuditLog` :
|
||||
- `entityType: 'machine'`
|
||||
- `entityId: $machine->getId()`
|
||||
- `action: 'update'`
|
||||
- `diff: { addedComponent: {id, name} }` ou `{ removedPiece: {id, name} }`
|
||||
- `snapshot:` snapshot complet de la machine (avec liens mis à jour)
|
||||
- `version:` nouvelle version
|
||||
|
||||
### Labels pour le diff (frontend)
|
||||
Ajouter au `historyFieldLabels` de la page machine :
|
||||
```js
|
||||
addedComponent: 'Composant ajouté',
|
||||
removedComponent: 'Composant supprimé',
|
||||
addedPiece: 'Pièce ajoutée',
|
||||
removedPiece: 'Pièce supprimée',
|
||||
addedProduct: 'Produit ajouté',
|
||||
removedProduct: 'Produit supprimé',
|
||||
```
|
||||
|
||||
## Ce qui ne change PAS
|
||||
|
||||
- Upload/suppression de documents (immédiat)
|
||||
- Pattern read/edit toggle dans le header
|
||||
- L'affichage des sections composants/pièces/produits
|
||||
- Les modales d'ajout/suppression de liens (restent immédiates)
|
||||
- Le versioning des autres entités (composant, pièce, produit)
|
||||
@@ -0,0 +1,60 @@
|
||||
# Spec : Formula Builder interactif pour la référence auto
|
||||
|
||||
**Date** : 2026-03-31
|
||||
**Scope** : Frontend uniquement (pas de changement backend)
|
||||
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
|
||||
## Problème
|
||||
|
||||
L'utilisateur doit taper manuellement les noms exacts des custom fields dans la formule (`{serie}{diametre}{type}`) et re-lister les champs requis séparés par des virgules. C'est sujet aux erreurs de typo et peu ergonomique.
|
||||
|
||||
## Solution
|
||||
|
||||
Remplacer la section "Génération de référence automatique" du `ModelTypeForm` par un formula builder interactif.
|
||||
|
||||
### Composants UI
|
||||
|
||||
#### 1. Chips de champs disponibles
|
||||
|
||||
- Afficher une rangée de boutons-chips avec les noms des custom fields définis dans `pieceStructure.customFields`
|
||||
- Cliquer sur un chip insère `{nom_du_champ}` dans l'input formule à la position du curseur
|
||||
- Si `pieceStructure.customFields` est vide, afficher un message "Aucun champ personnalisé défini"
|
||||
|
||||
#### 2. Input formule
|
||||
|
||||
- Input texte classique (comme aujourd'hui) mais avec les chips comme aide à la saisie
|
||||
- L'utilisateur peut aussi taper du texte libre (séparateurs `-`, `/`, préfixes `SNU `, etc.)
|
||||
- Le format stocké reste `{nom_du_champ}` — aucun changement de format backend
|
||||
|
||||
#### 3. Suppression du champ "Champs requis"
|
||||
|
||||
- Le champ `requiredFieldsForReference` est calculé automatiquement au submit en extrayant tous les `{...}` de la formule
|
||||
- Suppression de l'input "Champs requis" et de la variable `requiredFieldsInput`
|
||||
- La logique : tous les champs présents dans la formule sont requis. Si un champ n'a pas de valeur → pas de référence générée
|
||||
|
||||
#### 4. Aperçu live
|
||||
|
||||
- Conserver l'aperçu existant mais l'améliorer : remplacer les placeholders par des valeurs d'exemple en majuscules
|
||||
- Exemples par type de champ : `text` → `VALEUR`, `number` → `123`, `select` → `OPTION`, `boolean` → `OUI`, `date` → `2026-01-01`
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Insert au curseur** : quand l'utilisateur clique un chip, le placeholder est inséré à `selectionStart` de l'input, pas à la fin
|
||||
- **Formule vide** : si la formule est vide, pas de référence auto (comportement actuel conservé)
|
||||
- **Readonly** : les chips sont désactivés en mode readonly (comme l'input)
|
||||
- **Pas de custom fields** : si aucun champ n'est défini dans la structure, la section reste visible mais les chips sont remplacés par un message informatif. L'utilisateur peut quand même taper une formule manuellement (cas edge)
|
||||
|
||||
### Format de sortie (inchangé)
|
||||
|
||||
```typescript
|
||||
{
|
||||
referenceFormula: "SNU {serie}-{diametre}/{type}" | null,
|
||||
requiredFieldsForReference: ["serie", "diametre", "type"] | null // auto-calculé
|
||||
}
|
||||
```
|
||||
|
||||
### Pas de changement
|
||||
|
||||
- Backend (`ReferenceAutoGenerator`, `ReferenceAutoSubscriber`, entités) : aucun changement
|
||||
- Format de stockage de la formule : identique (`{placeholder}` strings)
|
||||
- API : identique
|
||||
30
migrations/Version20260326100000.php
Normal file
30
migrations/Version20260326100000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260326100000 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');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260326120000.php
Normal file
30
migrations/Version20260326120000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260326120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260331100000.php
Normal file
26
migrations/Version20260331100000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260331100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add referenceAuto to composants';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS referenceauto');
|
||||
}
|
||||
}
|
||||
155
src/Controller/EntityVersionController.php
Normal file
155
src/Controller/EntityVersionController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?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 InvalidArgumentException;
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ class AuditLog
|
||||
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
|
||||
private ?string $actorProfileId = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, nullable: true)]
|
||||
private ?int $version = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -48,6 +51,7 @@ class AuditLog
|
||||
?array $diff = null,
|
||||
?array $snapshot = null,
|
||||
?string $actorProfileId = null,
|
||||
?int $version = null,
|
||||
) {
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
@@ -55,6 +59,7 @@ class AuditLog
|
||||
$this->diff = $diff;
|
||||
$this->snapshot = $snapshot;
|
||||
$this->actorProfileId = $actorProfileId;
|
||||
$this->version = $version;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
@@ -109,6 +114,18 @@ class AuditLog
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getVersion(): ?int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function setVersion(?int $version): static
|
||||
{
|
||||
$this->version = $version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
// Keep the same lightweight CUID-like strategy used across the project.
|
||||
|
||||
@@ -64,6 +64,10 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $referenceAuto = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $description = null;
|
||||
@@ -145,6 +149,8 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -190,6 +196,21 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferenceAuto(): ?string
|
||||
{
|
||||
return $this->referenceAuto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal used by ReferenceAutoSubscriber only — not part of the public API
|
||||
*/
|
||||
public function setReferenceAuto(?string $referenceAuto): static
|
||||
{
|
||||
$this->referenceAuto = $referenceAuto;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
@@ -362,35 +383,41 @@ class Composant
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($this->pieceSlots as $slot) {
|
||||
$pieces[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
$selectedPiece = $slot->getSelectedPiece();
|
||||
$pieces[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $selectedPiece?->getId(),
|
||||
'selectedPieceName' => $selectedPiece?->getName(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($this->productSlots as $slot) {
|
||||
$products[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
$selectedProduct = $slot->getSelectedProduct();
|
||||
$products[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $selectedProduct?->getId(),
|
||||
'selectedProductName' => $selectedProduct?->getName(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($this->subcomponentSlots as $slot) {
|
||||
$subcomponents[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
'position' => $slot->getPosition(),
|
||||
$selectedComposant = $slot->getSelectedComposant();
|
||||
$subcomponents[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $selectedComposant?->getId(),
|
||||
'selectedComponentName' => $selectedComposant?->getName(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -454,4 +481,16 @@ class Composant
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,15 @@ class Machine
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $version = 1;
|
||||
|
||||
/**
|
||||
* Transient flag — when true, audit subscribers skip this entity.
|
||||
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
|
||||
*/
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
@@ -265,4 +274,28 @@ class Machine
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,14 @@ class ModelType
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['model_type:read', 'model_type:write'])]
|
||||
private ?string $referenceFormula = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['model_type:read', 'model_type:write'])]
|
||||
private ?array $requiredFieldsForReference = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -215,6 +223,30 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferenceFormula(): ?string
|
||||
{
|
||||
return $this->referenceFormula;
|
||||
}
|
||||
|
||||
public function setReferenceFormula(?string $referenceFormula): static
|
||||
{
|
||||
$this->referenceFormula = $referenceFormula;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequiredFieldsForReference(): ?array
|
||||
{
|
||||
return $this->requiredFieldsForReference;
|
||||
}
|
||||
|
||||
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
|
||||
{
|
||||
$this->requiredFieldsForReference = $requiredFieldsForReference;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
|
||||
@@ -63,6 +63,10 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $referenceAuto = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $description = null;
|
||||
@@ -133,6 +137,8 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['piece:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -177,6 +183,21 @@ class Piece
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferenceAuto(): ?string
|
||||
{
|
||||
return $this->referenceAuto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal used by ReferenceAutoSubscriber only — not part of the public API
|
||||
*/
|
||||
public function setReferenceAuto(?string $referenceAuto): static
|
||||
{
|
||||
$this->referenceAuto = $referenceAuto;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
@@ -354,4 +375,16 @@ class Piece
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ class Product
|
||||
#[Groups(['product:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
private bool $skipAudit = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['product:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -268,4 +270,16 @@ class Product
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSkipAudit(): bool
|
||||
{
|
||||
return $this->skipAudit;
|
||||
}
|
||||
|
||||
public function setSkipAudit(bool $skipAudit): static
|
||||
{
|
||||
$this->skipAudit = $skipAudit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,16 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$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();
|
||||
|
||||
@@ -106,7 +115,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($changeSet as $field => [$oldValue, $newValue]) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field) {
|
||||
if ('updatedAt' === $field || 'createdAt' === $field || 'version' === $field) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -117,6 +126,11 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip decimal formatting differences (e.g. "33.00" vs "33")
|
||||
if (is_numeric($normalizedOld) && is_numeric($normalizedNew) && (float) $normalizedOld === (float) $normalizedNew) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diff[$field] = [
|
||||
'from' => $normalizedOld,
|
||||
'to' => $normalizedNew,
|
||||
@@ -229,6 +243,43 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// If the version was already changed (e.g. by a sync strategy), don't double-increment
|
||||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
if (isset($changeSet['version'])) {
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
|
||||
protected function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
@@ -260,7 +311,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotEntity($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
$version = $this->getEntityVersion($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
@@ -275,8 +327,9 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$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));
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId, $version));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +356,8 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
|
||||
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
|
||||
$snapshot = $this->snapshotEntity($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
|
||||
$version = $this->getEntityVersion($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId, $version));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
@@ -352,8 +406,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId));
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,14 +34,64 @@ final class ComposantAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
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'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,38 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
{
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
// Let parent handle regular Machine entity changes (fields, collections, custom fields)
|
||||
parent::onFlush($args);
|
||||
|
||||
// Now handle link entity changes
|
||||
$em = $args->getObjectManager();
|
||||
if (!$em instanceof EntityManagerInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
|
||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||
}
|
||||
|
||||
protected function supports(object $entity): bool
|
||||
{
|
||||
return $entity instanceof Machine;
|
||||
@@ -36,13 +60,154 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
$componentLinks = [];
|
||||
foreach ($entity->getComponentLinks() as $link) {
|
||||
$componentLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$pieceLinks = [];
|
||||
foreach ($entity->getPieceLinks() as $link) {
|
||||
$pieceLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'pieceId' => $link->getPiece()->getId(),
|
||||
'pieceName' => $link->getPiece()->getName(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
];
|
||||
}
|
||||
|
||||
$productLinks = [];
|
||||
foreach ($entity->getProductLinks() as $link) {
|
||||
$productLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'productId' => $link->getProduct()->getId(),
|
||||
'productName' => $link->getProduct()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
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()),
|
||||
'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,
|
||||
'componentLinks' => $componentLinks,
|
||||
'pieceLinks' => $pieceLinks,
|
||||
'productLinks' => $productLinks,
|
||||
'version' => $this->safeGet($entity, 'getVersion'),
|
||||
];
|
||||
}
|
||||
|
||||
private function processLinkChanges(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId): void
|
||||
{
|
||||
$machineChanges = [];
|
||||
|
||||
// Detect inserted links
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
$info = $this->extractLinkInfo($entity, 'added');
|
||||
if (null === $info) {
|
||||
continue;
|
||||
}
|
||||
$machineId = (string) $info['machine']->getId();
|
||||
if ('' === $machineId) {
|
||||
continue;
|
||||
}
|
||||
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
|
||||
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
|
||||
'from' => null,
|
||||
'to' => $info['diffValue'],
|
||||
];
|
||||
}
|
||||
|
||||
// Detect deleted links
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
$info = $this->extractLinkInfo($entity, 'removed');
|
||||
if (null === $info) {
|
||||
continue;
|
||||
}
|
||||
$machineId = (string) $info['machine']->getId();
|
||||
if ('' === $machineId) {
|
||||
continue;
|
||||
}
|
||||
$machineChanges[$machineId] ??= ['machine' => $info['machine'], 'diffs' => []];
|
||||
$machineChanges[$machineId]['diffs'][$info['diffKey']] = [
|
||||
'from' => $info['diffValue'],
|
||||
'to' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Create audit logs for each affected machine
|
||||
foreach ($machineChanges as $machineId => $change) {
|
||||
$machine = $change['machine'];
|
||||
$diff = $change['diffs'];
|
||||
|
||||
if ([] === $diff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->incrementEntityVersion($machine, $em, $uow);
|
||||
$snapshot = $this->snapshotEntity($machine);
|
||||
|
||||
$this->persistAuditLog(
|
||||
$em,
|
||||
new AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId, $version),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{machine: Machine, diffKey: string, diffValue: array{id: string, name: string}}
|
||||
*/
|
||||
private function extractLinkInfo(object $entity, string $action): ?array
|
||||
{
|
||||
if ($entity instanceof MachineComponentLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Component',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getComposant()->getId(),
|
||||
'name' => $entity->getComposant()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entity instanceof MachinePieceLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Piece',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getPiece()->getId(),
|
||||
'name' => $entity->getPiece()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($entity instanceof MachineProductLink) {
|
||||
return [
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Product',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getProduct()->getId(),
|
||||
'name' => $entity->getProduct()->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,15 +34,39 @@ final class PieceAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
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'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'typePiece' => $this->normalizeValue($this->safeGet($entity, 'getTypePiece')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'productIds' => $this->safeGet($entity, 'getProductIds') ?? [],
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,25 @@ final class ProductAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
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()),
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
116
src/EventSubscriber/ReferenceAutoSubscriber.php
Normal file
116
src/EventSubscriber/ReferenceAutoSubscriber.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
use App\Service\ReferenceAutoGenerator;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
final class ReferenceAutoSubscriber
|
||||
{
|
||||
public function __construct(private readonly ReferenceAutoGenerator $generator) {}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $args): void
|
||||
{
|
||||
$em = $args->getObjectManager();
|
||||
$uow = $em->getUnitOfWork();
|
||||
|
||||
/** @var array<string, Piece> */
|
||||
$piecesToRecalculate = [];
|
||||
|
||||
/** @var array<string, Composant> */
|
||||
$composantsToRecalculate = [];
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof Piece) {
|
||||
$piecesToRecalculate[$entity->getId()] = $entity;
|
||||
} elseif ($entity instanceof Composant) {
|
||||
$composantsToRecalculate[$entity->getId()] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if ($entity instanceof Piece) {
|
||||
$piecesToRecalculate[$entity->getId()] = $entity;
|
||||
} elseif ($entity instanceof Composant) {
|
||||
$composantsToRecalculate[$entity->getId()] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
// For CFV insertions: the new CFV is not yet in the DB, so the lazy-loaded
|
||||
// collection won't contain it. We must add it manually so the generator sees it.
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
if (!$piece->getCustomFieldValues()->contains($entity)) {
|
||||
$piece->getCustomFieldValues()->add($entity);
|
||||
}
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
if (!$composant->getCustomFieldValues()->contains($entity)) {
|
||||
$composant->getCustomFieldValues()->add($entity);
|
||||
}
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
// For CFV deletions: remove from collection so the generator doesn't see stale values.
|
||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||
if (!$entity instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
if ($entity->getPiece()) {
|
||||
$piece = $entity->getPiece();
|
||||
$piece->getCustomFieldValues()->removeElement($entity);
|
||||
$piecesToRecalculate[$piece->getId()] = $piece;
|
||||
} elseif ($entity->getComposant()) {
|
||||
$composant = $entity->getComposant();
|
||||
$composant->getCustomFieldValues()->removeElement($entity);
|
||||
$composantsToRecalculate[$composant->getId()] = $composant;
|
||||
}
|
||||
}
|
||||
|
||||
$pieceMeta = $em->getClassMetadata(Piece::class);
|
||||
$composantMeta = $em->getClassMetadata(Composant::class);
|
||||
|
||||
foreach ($piecesToRecalculate as $piece) {
|
||||
$newRef = $this->generator->generate($piece);
|
||||
if ($piece->getReferenceAuto() !== $newRef) {
|
||||
$piece->setReferenceAuto($newRef);
|
||||
$uow->recomputeSingleEntityChangeSet($pieceMeta, $piece);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($composantsToRecalculate as $composant) {
|
||||
$newRef = $this->generator->generate($composant);
|
||||
if ($composant->getReferenceAuto() !== $newRef) {
|
||||
$composant->setReferenceAuto($newRef);
|
||||
$uow->recomputeSingleEntityChangeSet($composantMeta, $composant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,38 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{entityType?: string, action?: string} $filters
|
||||
*
|
||||
|
||||
918
src/Service/EntityVersionService.php
Normal file
918
src/Service/EntityVersionService.php
Normal file
@@ -0,0 +1,918 @@
|
||||
<?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\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
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 InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Throwable;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Machine: check link references
|
||||
if ('machine' === $entityType) {
|
||||
$linkChecks = [
|
||||
['key' => 'componentLinks', 'refKey' => 'composantId', 'nameKey' => 'composantName', 'label' => 'composant', 'repo' => $this->composants],
|
||||
['key' => 'pieceLinks', 'refKey' => 'pieceId', 'nameKey' => 'pieceName', 'label' => 'pièce', 'repo' => $this->pieces],
|
||||
['key' => 'productLinks', 'refKey' => 'productId', 'nameKey' => 'productName', 'label' => 'produit', 'repo' => $this->products],
|
||||
];
|
||||
|
||||
foreach ($linkChecks as $check) {
|
||||
$links = $snapshot[$check['key']] ?? [];
|
||||
$refIds = [];
|
||||
foreach ($links as $link) {
|
||||
$refId = $link[$check['refKey']] ?? null;
|
||||
if (null !== $refId) {
|
||||
$refIds[$refId] = $link[$check['nameKey']] ?? $refId;
|
||||
}
|
||||
}
|
||||
if ([] === $refIds) {
|
||||
continue;
|
||||
}
|
||||
$foundIds = array_map(fn ($e) => $e->getId(), $check['repo']->findBy(['id' => array_keys($refIds)]));
|
||||
foreach ($refIds as $id => $name) {
|
||||
if (!in_array($id, $foundIds, true)) {
|
||||
$warnings[] = [
|
||||
'field' => $check['key'],
|
||||
'message' => sprintf("Le %s '%s' n'existe plus. Le lien ne sera pas restauré.", $check['label'], $name),
|
||||
'missingEntityId' => $id,
|
||||
'missingEntityName' => $name,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]))];
|
||||
}
|
||||
}
|
||||
|
||||
// Machine: link diffs
|
||||
if ('machine' === $entityType) {
|
||||
$linkTypes = [
|
||||
'componentLinks' => ['idKey' => 'composantId', 'nameKey' => 'composantName', 'getter' => 'getComponentLinks', 'entityGetter' => 'getComposant'],
|
||||
'pieceLinks' => ['idKey' => 'pieceId', 'nameKey' => 'pieceName', 'getter' => 'getPieceLinks', 'entityGetter' => 'getPiece'],
|
||||
'productLinks' => ['idKey' => 'productId', 'nameKey' => 'productName', 'getter' => 'getProductLinks', 'entityGetter' => 'getProduct'],
|
||||
];
|
||||
|
||||
foreach ($linkTypes as $key => $config) {
|
||||
$currentIds = [];
|
||||
$currentNames = [];
|
||||
if (method_exists($entity, $config['getter'])) {
|
||||
foreach ($entity->{$config['getter']}() as $link) {
|
||||
$linked = $link->{$config['entityGetter']}();
|
||||
$currentIds[] = $linked->getId();
|
||||
$currentNames[$linked->getId()] = $linked->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$snapshotIds = [];
|
||||
$snapshotNames = [];
|
||||
foreach ($snapshot[$key] ?? [] as $entry) {
|
||||
$id = $entry[$config['idKey']] ?? null;
|
||||
if ($id) {
|
||||
$snapshotIds[] = $id;
|
||||
$snapshotNames[$id] = $entry[$config['nameKey']] ?? $id;
|
||||
}
|
||||
}
|
||||
|
||||
sort($currentIds);
|
||||
sort($snapshotIds);
|
||||
if ($currentIds !== $snapshotIds) {
|
||||
$currentDisplay = array_map(fn ($id) => $currentNames[$id] ?? $id, $currentIds);
|
||||
$restoredDisplay = array_map(fn ($id) => $snapshotNames[$id] ?? $id, $snapshotIds);
|
||||
$diff[$key] = [
|
||||
'current' => [] !== $currentDisplay ? implode(', ', $currentDisplay) : '(aucun)',
|
||||
'restored' => [] !== $restoredDisplay ? implode(', ', $restoredDisplay) : '(aucun)',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom field values diff
|
||||
$snapshotCfvs = $snapshot['customFieldValues'] ?? [];
|
||||
if ([] !== $snapshotCfvs && method_exists($entity, 'getCustomFieldValues')) {
|
||||
$currentCfvsByFieldId = [];
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$fieldId = $cfv->getCustomField()?->getId();
|
||||
if (null !== $fieldId) {
|
||||
$currentCfvsByFieldId[$fieldId] = [
|
||||
'fieldName' => $cfv->getCustomField()->getName(),
|
||||
'value' => $cfv->getValue(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshotCfvs as $cfvData) {
|
||||
$fieldId = $cfvData['fieldId'] ?? null;
|
||||
$fieldName = $cfvData['fieldName'] ?? $fieldId;
|
||||
if (!$fieldId) {
|
||||
continue;
|
||||
}
|
||||
$currentValue = $currentCfvsByFieldId[$fieldId]['value'] ?? null;
|
||||
$restoredValue = $cfvData['value'] ?? null;
|
||||
if ((string) ($currentValue ?? '') !== (string) ($restoredValue ?? '')) {
|
||||
$diff['customField:'.$fieldName] = ['current' => $currentValue, 'restored' => $restoredValue];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// Machine: restore links
|
||||
if ('machine' === $entityType) {
|
||||
$this->restoreMachineLinks($entity, $snapshot);
|
||||
}
|
||||
|
||||
// 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 restoreMachineLinks(Machine $machine, array $snapshot): void
|
||||
{
|
||||
// Remove all existing links
|
||||
foreach ($machine->getProductLinks()->toArray() as $link) {
|
||||
$this->em->remove($link);
|
||||
}
|
||||
foreach ($machine->getPieceLinks()->toArray() as $link) {
|
||||
$this->em->remove($link);
|
||||
}
|
||||
foreach ($machine->getComponentLinks()->toArray() as $link) {
|
||||
$this->em->remove($link);
|
||||
}
|
||||
|
||||
// Flush removals to avoid FK conflicts
|
||||
$this->em->flush();
|
||||
|
||||
// Recreate component links
|
||||
foreach ($snapshot['componentLinks'] ?? [] as $data) {
|
||||
$composant = !empty($data['composantId']) ? $this->composants->find($data['composantId']) : null;
|
||||
if (null === $composant) {
|
||||
continue;
|
||||
}
|
||||
$link = new MachineComponentLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
$this->em->persist($link);
|
||||
}
|
||||
|
||||
// Recreate piece links
|
||||
foreach ($snapshot['pieceLinks'] ?? [] as $data) {
|
||||
$piece = !empty($data['pieceId']) ? $this->pieces->find($data['pieceId']) : null;
|
||||
if (null === $piece) {
|
||||
continue;
|
||||
}
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
if (isset($data['quantity']) && $data['quantity'] > 0) {
|
||||
$link->setQuantity((int) $data['quantity']);
|
||||
}
|
||||
$this->em->persist($link);
|
||||
}
|
||||
|
||||
// Recreate product links
|
||||
foreach ($snapshot['productLinks'] ?? [] as $data) {
|
||||
$product = !empty($data['productId']) ? $this->products->find($data['productId']) : null;
|
||||
if (null === $product) {
|
||||
continue;
|
||||
}
|
||||
$link = new MachineProductLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
$this->em->persist($link);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Machine: links
|
||||
if ('machine' === $entityType) {
|
||||
$snapshot['componentLinks'] = [];
|
||||
foreach ($entity->getComponentLinks() as $link) {
|
||||
$snapshot['componentLinks'][] = [
|
||||
'id' => $link->getId(), 'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
];
|
||||
}
|
||||
$snapshot['pieceLinks'] = [];
|
||||
foreach ($entity->getPieceLinks() as $link) {
|
||||
$snapshot['pieceLinks'][] = [
|
||||
'id' => $link->getId(), 'pieceId' => $link->getPiece()->getId(),
|
||||
'pieceName' => $link->getPiece()->getName(), 'quantity' => $link->getQuantity(),
|
||||
];
|
||||
}
|
||||
$snapshot['productLinks'] = [];
|
||||
foreach ($entity->getProductLinks() as $link) {
|
||||
$snapshot['productLinks'][] = [
|
||||
'id' => $link->getId(), 'productId' => $link->getProduct()->getId(),
|
||||
'productName' => $link->getProduct()->getName(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
57
src/Service/ReferenceAutoGenerator.php
Normal file
57
src/Service/ReferenceAutoGenerator.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Piece;
|
||||
|
||||
class ReferenceAutoGenerator
|
||||
{
|
||||
public function generate(Composant|Piece $entity): ?string
|
||||
{
|
||||
$modelType = $entity instanceof Piece
|
||||
? $entity->getTypePiece()
|
||||
: $entity->getTypeComposant();
|
||||
|
||||
if (!$modelType || !$modelType->getReferenceFormula()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$valueMap = $this->buildValueMap($entity);
|
||||
|
||||
$requiredFields = $modelType->getRequiredFieldsForReference();
|
||||
|
||||
if ($requiredFields) {
|
||||
foreach ($requiredFields as $fieldName) {
|
||||
if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
|
||||
return $valueMap[$matches[1]] ?? '';
|
||||
}, $modelType->getReferenceFormula());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of fieldName → normalized value from the entity's CustomFieldValues.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildValueMap(Composant|Piece $entity): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
/** @var CustomFieldValue $cfv */
|
||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||
$normalized = mb_strtoupper(trim($cfv->getValue()));
|
||||
$map[$cfv->getCustomField()->getName()] = $normalized;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
174
tests/Api/Controller/EntityVersionTest.php
Normal file
174
tests/Api/Controller/EntityVersionTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
178
tests/Api/Entity/PieceReferenceAutoTest.php
Normal file
178
tests/Api/Entity/PieceReferenceAutoTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Entity;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class PieceReferenceAutoTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testReferenceAutoGeneratedAfterAllCfvCreated(): void
|
||||
{
|
||||
$mt = $this->createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('{serie}{diametre}{type}');
|
||||
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
|
||||
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
|
||||
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
|
||||
|
||||
$piece = $this->createPiece('Roulement Auto', null, $mt);
|
||||
|
||||
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
|
||||
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
|
||||
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['referenceAuto' => '2207K']);
|
||||
}
|
||||
|
||||
public function testReferenceAutoNullWhenNoFormula(): void
|
||||
{
|
||||
$mt = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE);
|
||||
$piece = $this->createPiece('Galet Auto', null, $mt);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$response = $client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be serialized when null');
|
||||
}
|
||||
|
||||
public function testReferenceAutoNullWhenRequiredFieldsMissing(): void
|
||||
{
|
||||
$mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('SNU {taille}');
|
||||
$mt->setRequiredFieldsForReference(['taille']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$piece = $this->createPiece('Palier Sans Champ', null, $mt);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$response = $client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be serialized when required fields missing');
|
||||
}
|
||||
|
||||
public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void
|
||||
{
|
||||
$mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('U{taille}');
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
|
||||
$piece = $this->createPiece('Joint Upd', null, $mt);
|
||||
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['referenceAuto' => 'U507']);
|
||||
|
||||
// Update CFV value via API
|
||||
$gClient = $this->createGestionnaireClient();
|
||||
$gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['value' => '608'],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Re-read the Piece to check updated referenceAuto
|
||||
$viewer = $this->createViewerClient();
|
||||
$viewer->request('GET', self::iri('pieces', $piece->getId()));
|
||||
$this->assertJsonContains(['referenceAuto' => 'U608']);
|
||||
}
|
||||
|
||||
public function testReferenceAutoNullAfterRequiredCfvDeleted(): void
|
||||
{
|
||||
$mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('U{taille}');
|
||||
$mt->setRequiredFieldsForReference(['taille']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
|
||||
$piece = $this->createPiece('Joint Del', null, $mt);
|
||||
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
$this->assertJsonContains(['referenceAuto' => 'U507']);
|
||||
|
||||
// Delete the CFV
|
||||
$gClient = $this->createGestionnaireClient();
|
||||
$gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
|
||||
$this->assertResponseStatusCodeSame(204);
|
||||
|
||||
// Re-read piece — referenceAuto should now be absent (null = not serialized)
|
||||
$viewer = $this->createViewerClient();
|
||||
$response = $viewer->request('GET', self::iri('pieces', $piece->getId()));
|
||||
$data = $response->toArray();
|
||||
self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should be null after required CFV deleted');
|
||||
}
|
||||
|
||||
public function testReferenceAutoIsReadOnlyViaApi(): void
|
||||
{
|
||||
$piece = $this->createPiece('ReadOnly Test');
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('pieces', $piece->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['referenceAuto' => 'HACKED'],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// referenceAuto should still be null (no formula) — subscriber overwrites
|
||||
$viewer = $this->createViewerClient();
|
||||
$response = $viewer->request('GET', self::iri('pieces', $piece->getId()));
|
||||
$data = $response->toArray();
|
||||
self::assertArrayNotHasKey('referenceAuto', $data, 'referenceAuto should not be settable via API');
|
||||
}
|
||||
|
||||
public function testReferenceAutoNormalizesLowercaseValues(): void
|
||||
{
|
||||
$mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('{serie}{diametre}{type}');
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
|
||||
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
|
||||
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
|
||||
|
||||
$piece = $this->createPiece('Roulement Norm', null, $mt);
|
||||
|
||||
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
|
||||
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
|
||||
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', self::iri('pieces', $piece->getId()));
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains(['referenceAuto' => '2207K']);
|
||||
}
|
||||
}
|
||||
167
tests/Service/ReferenceAutoGeneratorTest.php
Normal file
167
tests/Service/ReferenceAutoGeneratorTest.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ReferenceAutoGeneratorTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testGenerateWithFormula(): void
|
||||
{
|
||||
$mt = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('{serie}{diametre}{type}');
|
||||
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
|
||||
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
|
||||
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
|
||||
|
||||
$piece = $this->createPiece('Roulement Test', null, $mt);
|
||||
|
||||
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
|
||||
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
|
||||
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
|
||||
|
||||
$em->refresh($piece);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertSame('2207K', $result);
|
||||
}
|
||||
|
||||
public function testGenerateNormalizesValues(): void
|
||||
{
|
||||
$mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('{serie}{diametre}{type}');
|
||||
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
|
||||
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
|
||||
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
|
||||
|
||||
$piece = $this->createPiece('Roulement Norm', null, $mt);
|
||||
|
||||
$this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece);
|
||||
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
|
||||
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
|
||||
|
||||
$em->refresh($piece);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertSame('2207K', $result);
|
||||
}
|
||||
|
||||
public function testGenerateReturnsNullWithoutFormula(): void
|
||||
{
|
||||
$mt = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE);
|
||||
$piece = $this->createPiece('Galet Test', null, $mt);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testGenerateReturnsNullWhenNoModelType(): void
|
||||
{
|
||||
$piece = $this->createPiece('Orphan Piece');
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void
|
||||
{
|
||||
$mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('SNU {taille}');
|
||||
$mt->setRequiredFieldsForReference(['taille']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$piece = $this->createPiece('Palier Test', null, $mt);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void
|
||||
{
|
||||
$mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('SNU {taille}');
|
||||
$mt->setRequiredFieldsForReference(['taille']);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
|
||||
$piece = $this->createPiece('Palier Vide', null, $mt);
|
||||
$this->createCustomFieldValue($cfTaille, ' ', piece: $piece);
|
||||
|
||||
$em->refresh($piece);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testGenerateWithStaticTextInFormula(): void
|
||||
{
|
||||
$mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('U{taille}');
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
|
||||
$piece = $this->createPiece('Joint Test', null, $mt);
|
||||
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
|
||||
|
||||
$em->refresh($piece);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertSame('U507', $result);
|
||||
}
|
||||
|
||||
public function testGenerateWithSpaceInFormula(): void
|
||||
{
|
||||
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
|
||||
$mt->setReferenceFormula('SNU {taille}');
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->flush();
|
||||
|
||||
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
|
||||
$piece = $this->createPiece('Palier Test 2', null, $mt);
|
||||
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
|
||||
|
||||
$em->refresh($piece);
|
||||
|
||||
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||
$result = $generator->generate($piece);
|
||||
|
||||
self::assertSame('SNU 507', $result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user