From 03c2451990a4dadb78eef3ee7cc7bf11373cdc99 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 31 Mar 2026 09:59:42 +0200 Subject: [PATCH] feat(reference-auto) : extend auto-reference to composants + formula builder UI Co-Authored-By: Claude Opus 4.6 (1M context) --- Inventory_frontend | 2 +- ...-03-31-reference-formula-builder-design.md | 60 ++++++++++++++++++ migrations/Version20260331100000.php | 26 ++++++++ src/Entity/Composant.php | 63 +++++++++++++------ .../ReferenceAutoSubscriber.php | 53 +++++++++++++--- src/Service/ReferenceAutoGenerator.php | 15 +++-- 6 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 docs/superpowers/specs/2026-03-31-reference-formula-builder-design.md create mode 100644 migrations/Version20260331100000.php diff --git a/Inventory_frontend b/Inventory_frontend index a741596..c82c21c 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit a7415964a718bef7c08af6d6399afd778e45b841 +Subproject commit c82c21c0cdf069349411393f9d73199ca9879797 diff --git a/docs/superpowers/specs/2026-03-31-reference-formula-builder-design.md b/docs/superpowers/specs/2026-03-31-reference-formula-builder-design.md new file mode 100644 index 0000000..c18212e --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-reference-formula-builder-design.md @@ -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 diff --git a/migrations/Version20260331100000.php b/migrations/Version20260331100000.php new file mode 100644 index 0000000..f6c6ee0 --- /dev/null +++ b/migrations/Version20260331100000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/src/Entity/Composant.php b/src/Entity/Composant.php index 042aba4..0aeb337 100644 --- a/src/Entity/Composant.php +++ b/src/Entity/Composant.php @@ -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; @@ -192,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; @@ -364,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(), ]; } diff --git a/src/EventSubscriber/ReferenceAutoSubscriber.php b/src/EventSubscriber/ReferenceAutoSubscriber.php index 3856263..455e3da 100644 --- a/src/EventSubscriber/ReferenceAutoSubscriber.php +++ b/src/EventSubscriber/ReferenceAutoSubscriber.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\EventSubscriber; +use App\Entity\Composant; use App\Entity\CustomFieldValue; use App\Entity\Piece; use App\Service\ReferenceAutoGenerator; @@ -21,56 +22,94 @@ final class ReferenceAutoSubscriber $em = $args->getObjectManager(); $uow = $em->getUnitOfWork(); + /** @var array */ $piecesToRecalculate = []; + /** @var array */ + $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 Piece's lazy-loaded + // 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 && $entity->getPiece()) { + 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 && $entity->getPiece()) { + 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 && $entity->getPiece()) { + 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; } } - $meta = $em->getClassMetadata(Piece::class); + $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($meta, $piece); + $uow->recomputeSingleEntityChangeSet($pieceMeta, $piece); + } + } + + foreach ($composantsToRecalculate as $composant) { + $newRef = $this->generator->generate($composant); + if ($composant->getReferenceAuto() !== $newRef) { + $composant->setReferenceAuto($newRef); + $uow->recomputeSingleEntityChangeSet($composantMeta, $composant); } } } diff --git a/src/Service/ReferenceAutoGenerator.php b/src/Service/ReferenceAutoGenerator.php index 560e847..a1e8e7b 100644 --- a/src/Service/ReferenceAutoGenerator.php +++ b/src/Service/ReferenceAutoGenerator.php @@ -4,20 +4,23 @@ declare(strict_types=1); namespace App\Service; +use App\Entity\Composant; use App\Entity\CustomFieldValue; use App\Entity\Piece; class ReferenceAutoGenerator { - public function generate(Piece $piece): ?string + public function generate(Composant|Piece $entity): ?string { - $modelType = $piece->getTypePiece(); + $modelType = $entity instanceof Piece + ? $entity->getTypePiece() + : $entity->getTypeComposant(); if (!$modelType || !$modelType->getReferenceFormula()) { return null; } - $valueMap = $this->buildValueMap($piece); + $valueMap = $this->buildValueMap($entity); $requiredFields = $modelType->getRequiredFieldsForReference(); @@ -35,16 +38,16 @@ class ReferenceAutoGenerator } /** - * Build a map of fieldName → normalized value from the Piece's CustomFieldValues. + * Build a map of fieldName → normalized value from the entity's CustomFieldValues. * * @return array */ - private function buildValueMap(Piece $piece): array + private function buildValueMap(Composant|Piece $entity): array { $map = []; /** @var CustomFieldValue $cfv */ - foreach ($piece->getCustomFieldValues() as $cfv) { + foreach ($entity->getCustomFieldValues() as $cfv) { $normalized = mb_strtoupper(trim($cfv->getValue())); $map[$cfv->getCustomField()->getName()] = $normalized; }