feat(reference-auto) : extend auto-reference to composants + formula builder UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Submodule Inventory_frontend updated: a7415964a7...c82c21c0cd
@@ -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
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user