Compare commits

...

22 Commits

Author SHA1 Message Date
gitea-actions
b3fa927e77 chore : bump version to v1.9.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu
f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
gitea-actions
905d5c0957 chore : bump version to v1.9.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-05-13 08:23:57 +00:00
Matthieu
03a5d05a2c feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00
gitea-actions
069cc6e153 chore : bump version to v1.9.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-13 08:02:55 +00:00
Matthieu
daa0cb1e28 feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N)
- Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write
- Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone
- Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories
- MAJ MCP tools / MachineStructureController / audit subscriber / tests
- Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:02:44 +02:00
gitea-actions
b147845401 chore : bump version to v1.9.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-11 14:52:40 +00:00
Matthieu
b67af56bd1 fix(search-select) : affiche modelValue au mount en mode creatable
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
En mode creatable, modelValue n'est pas dans options donc selectedOption est null.
Le onMounted ecrasait searchTerm a vide apres que le watch immediate l'avait initialise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:52:28 +02:00
gitea-actions
48c5c5bb33 chore : bump version to v1.9.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m47s
2026-05-11 14:25:24 +00:00
1e2a1dae62 Merge pull request 'feat(custom-fields) : autocomplete des noms + corrections formule de référence auto' (#3) from feat/custom-field-name-autocomplete into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3
2026-05-11 14:25:14 +00:00
Matthieu
2a8042ba50 fix(custom-fields) : recharge la formule depuis le BE apres save du ModelType
Sur les pages d'edition de categorie composant/piece, ajoute un
loadCategory() apres updateModelType + syncExecute pour que la formule
mise a jour par propagateCustomFieldRename soit refletee dans le form
sans avoir a recharger la page.
2026-05-11 16:22:58 +02:00
Matthieu
bc32648918 fix(custom-fields) : supporte les caracteres accentues dans les placeholders de formule
La regex \w+ ne capturait pas les caracteres accentues (ex. {Diametre}
avec 'è'), le placeholder restait litteral dans la reference auto.
Remplace par [^}]+ avec le flag u/gu cote PHP et JS pour matcher
n'importe quel caractere entre les accolades.
2026-05-11 16:22:52 +02:00
Matthieu
9027917ea2 fix(custom-fields) : propage le renommage d'un champ dans la formule de reference auto 2026-05-11 16:22:28 +02:00
Matthieu
5244698384 fix(custom-fields) : retire le prefixe /api en double dans l'appel API
useApi() prepend deja apiBaseUrl (= /api), donc l'appel doit etre
/custom-fields/names et non /api/custom-fields/names (sinon 404 sur
/api/api/custom-fields/names).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:26:46 +02:00
Matthieu
17ca857cc3 feat(custom-fields) : invalide le cache de suggestions apres save
Apres chaque save reussi de champs perso (machine via
useMachineCustomFieldDefs, ModelType via useEntityTypes), on invalide
le cache useCustomFieldNameSuggestions pour que les noms nouvellement
crees apparaissent dans les futures autocomplete.

Note : le plan mentionnait ModelTypeForm.vue, mais le save reel se
fait dans useEntityTypes (le composant ne fait qu'emit 'submit').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:09:17 +02:00
Matthieu
e6a85a9de4 feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:59:12 +02:00
Matthieu
a4ea44675a feat(custom-fields) : ajoute CustomFieldNameInput wrapper
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
Suggestions, charge la liste au focus. Permet de remplacer un simple
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
dans les editeurs de champs perso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:51:15 +02:00
Matthieu
e5d0c690b7 feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
Cache module-level partage entre toutes les instances. Lazy load au
premier appel a load(). invalidate() permet de forcer un refresh apres
creation/modification d'un champ perso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:40:21 +02:00
Matthieu
0255d7dda1 feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
En mode creatable=true, le composant emit le texte tape en temps reel
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
ne matche aucune option. Mode strict (defaut) inchange. Le composant
emit aussi 'focus' pour permettre au parent de charger les donnees au
premier focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:32:06 +02:00
Matthieu
dd7ab2b8e7 feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
Retourne la liste plate des noms de champs perso distincts (table
custom_fields), pour alimenter une autocompletion cote frontend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:14:32 +02:00
Matthieu
73c06169f3 docs(custom-fields) : corrige la source de verite (table custom_fields unique)
L'investigation initiale supposait des customFields JSON dans les
skeleton_*_requirements ; en realite SkeletonStructureService traduit
les customFields du payload ModelType en entites CustomField stockees
dans la table custom_fields. Le SQL est donc un simple SELECT DISTINCT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:33:06 +02:00
Matthieu
5e8e7947f0 docs(custom-fields) : design pour autocomplete des noms de champs perso
Spec validee : endpoint backend qui agrege les noms existants (table
custom_fields + JSON dans skeleton requirements), composant
CustomFieldNameInput qui wrap SearchSelect en mode creatable, cache
module-level partage entre toutes les instances, invalidation apres save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:29:07 +02:00
54 changed files with 3185 additions and 177 deletions

View File

@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
```
@@ -116,7 +121,9 @@ Le frontend est un submodule git. Lors d'un commit frontend :
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
#### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
@@ -199,6 +206,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
@@ -256,7 +264,7 @@ make test-setup # Créer/mettre à jour le schéma test
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales

View File

@@ -69,3 +69,8 @@ when@test:
autowire: true
autoconfigure: true
public: true
App\Service\SkeletonStructureService:
autowire: true
autoconfigure: true
public: true

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.30'
app.version: '1.9.35'

View File

@@ -0,0 +1,926 @@
# Custom Field Name Autocomplete — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux.
**Architecture:**
- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`.
- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs.
**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI.
**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md`
---
## Task 1: Backend — Controller `CustomFieldNamesController`
**Files:**
- Create: `src/Controller/CustomFieldNamesController.php`
- [ ] **Step 1: Créer le controller**
Créer `src/Controller/CustomFieldNamesController.php` avec le contenu :
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
final class CustomFieldNamesController
{
public function __construct(private readonly Connection $connection)
{
}
#[Route(
path: '/api/custom-fields/names',
name: 'api_custom_fields_names',
methods: ['GET']
)]
#[IsGranted('ROLE_VIEWER')]
public function __invoke(): JsonResponse
{
$sql = <<<'SQL'
SELECT DISTINCT name
FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
SQL;
$names = $this->connection->fetchFirstColumn($sql);
return new JsonResponse($names);
}
}
```
- [ ] **Step 2: Vérifier que la route est bien exposée**
Exécuter :
```bash
docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names
```
Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`.
- [ ] **Step 3: Tester manuellement le endpoint**
```bash
curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \
http://localhost:8081/api/custom-fields/names | head -c 500
```
> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch).
Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide).
- [ ] **Step 4: Lancer php-cs-fixer**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CustomFieldNamesController.php
git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
Retourne la liste plate des noms de champs perso distincts (table
custom_fields), pour alimenter une autocompletion cote frontend."
```
> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer.
---
## Task 2: Backend — Test PHPUnit du endpoint
**Files:**
- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php`
- [ ] **Step 1: Repérer un test existant à copier-coller pour le style**
Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve.
```bash
ls tests/Api/Controller/ | head
```
- [ ] **Step 2: Créer le test**
Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
final class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseStatusCodeSame(401);
}
public function testReturnsEmptyArrayWhenNoCustomFields(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($data);
}
public function testReturnsDistinctSortedNames(): void
{
// Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon)
$machine1 = $this->createMachine();
$this->createCustomField(['name' => 'Tension', 'machine' => $machine1]);
$this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]);
$machine2 = $this->createMachine();
$this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('Tension', $data);
self::assertContains('Numéro de série', $data);
// Pas de doublon
self::assertSame(count(array_unique($data)), count($data));
// Tri alpha
$sorted = $data;
sort($sorted, SORT_STRING);
self::assertSame($sorted, $data);
}
}
```
> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet.
- [ ] **Step 3: Vérifier que les helpers utilisés existent**
```bash
grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head
```
Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers.
- [ ] **Step 4: Lancer le test ciblé**
```bash
make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php
```
Attendu : 3 tests OK.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Controller/CustomFieldNamesControllerTest.php
git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names"
```
---
## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable`
**Files:**
- Modify: `frontend/app/components/common/SearchSelect.vue`
- [ ] **Step 1: Ajouter le prop `creatable` au composant**
Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` :
```js
creatable: {
type: Boolean,
default: false
}
```
- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable**
Remplacer la fonction `handleInput` (lignes ~284-289) par :
```js
function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
if (props.creatable) {
emit('update:modelValue', searchTerm.value)
}
emit('search', searchTerm.value)
}
```
- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable**
Remplacer la fonction `closeDropdown` (lignes ~297-304) par :
```js
function closeDropdown () {
openDropdown.value = false
if (props.creatable) {
return // garde le texte tapé tel quel
}
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
```
- [ ] **Step 4: Ajouter une computed `creatableSuggestion`**
Dans le bloc `<script setup>`, après la `computed displayedOptions` (ligne ~173), ajouter :
```js
const creatableSuggestion = computed(() => {
if (!props.creatable) return null
const term = searchTerm.value.trim()
if (!term) return null
// Affiche "Créer ..." uniquement si aucune option exacte ne matche (case-insensitive)
const exists = baseOptions.value.some(option => {
const label = resolveLabel(option).toLowerCase()
return label === term.toLowerCase()
})
return exists ? null : term
})
```
- [ ] **Step 5: Afficher la ligne "Créer ..." dans le template**
Localiser le bloc dropdown (lignes ~40-81). Juste **après** la `<ul>` qui contient les options (donc juste avant la fermeture du `<div v-if="openDropdown">`), ajouter :
```vue
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
```
Ajouter l'import en haut :
```js
import IconLucidePlus from '~icons/lucide/plus'
```
- [ ] **Step 6: Ajouter la fonction `confirmCreatable`**
Après `clearSelection` (ligne ~295), ajouter :
```js
function confirmCreatable () {
if (creatableSuggestion.value) {
emit('update:modelValue', creatableSuggestion.value)
}
openDropdown.value = false
}
```
- [ ] **Step 7: Ajuster la sync `searchTerm` ↔ `modelValue` en mode creatable**
Localiser le `watch` sur `modelValue` (lignes ~194-202). Remplacer par :
```js
watch(
() => props.modelValue,
() => {
if (props.creatable) {
if (searchTerm.value !== props.modelValue) {
searchTerm.value = String(props.modelValue ?? '')
}
return
}
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
},
{ immediate: true }
)
```
> En mode creatable, `modelValue` et `searchTerm` reflètent la même chose (le texte tapé) — on évite juste la boucle infinie en testant l'égalité.
- [ ] **Step 8: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 9: Lancer ESLint**
```bash
cd frontend && npm run lint:fix
```
- [ ] **Step 10: Test de non-régression manuel**
Vérifier qu'un usage existant de `SearchSelect` (par exemple sur la page `frontend/app/pages/index.vue` ou similaire — chercher `<SearchSelect`) fonctionne toujours **sans** le prop `creatable` : le comportement strict doit être identique à avant.
```bash
cd frontend && grep -rln "SearchSelect" app/ | head -5
```
Ouvrir un de ces écrans en dev et vérifier que :
- La sélection d'une option marche
- Le blur sans sélection reset au label précédent (= mode strict inchangé)
- [ ] **Step 11: Commit**
```bash
git add frontend/app/components/common/SearchSelect.vue
git commit -m "feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
En mode creatable=true, le composant emit le texte tape en temps reel
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
ne matche aucune option. Mode strict (defaut) inchange."
```
---
## Task 4: Frontend — Composable `useCustomFieldNameSuggestions`
**Files:**
- Create: `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- [ ] **Step 1: Vérifier le pattern `useApi` existant**
```bash
cat frontend/app/composables/useApi.ts | head -30
```
Noter la signature exacte (`<T>(path, opts?) => Promise<T>` ou autre) pour l'adapter.
- [ ] **Step 2: Créer le composable**
Créer `frontend/app/composables/useCustomFieldNameSuggestions.ts` :
```ts
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
interface Deps {
api: ReturnType<typeof useApi>
}
export function useCustomFieldNameSuggestions(deps: Deps) {
const { api } = deps
async function load(force = false): Promise<string[]> {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
const result = await api<string[]>('/api/custom-fields/names')
cache.value = Array.isArray(result) ? result : []
return cache.value
} catch (err) {
console.error('[useCustomFieldNameSuggestions] failed to load', err)
cache.value = cache.value ?? []
return cache.value
} finally {
loading.value = false
}
}
function invalidate(): void {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
> Note : `cache` et `loading` sont déclarés **au niveau du module** (hors de la fonction) → cache partagé entre toutes les instances.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors. Si le `ReturnType<typeof useApi>` pose souci (selon comment `useApi` est typé), remplacer par un type explicite plus simple :
```ts
interface Deps {
api: <T>(path: string, opts?: RequestInit) => Promise<T>
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/app/composables/useCustomFieldNameSuggestions.ts
git commit -m "feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
Cache module-level partage entre toutes les instances. Lazy load au
premier appel a load(). invalidate() permet de forcer un refresh apres
creation/modification d'un champ perso."
```
---
## Task 5: Frontend — Composant wrapper `CustomFieldNameInput`
**Files:**
- Create: `frontend/app/components/common/CustomFieldNameInput.vue`
- [ ] **Step 1: Créer le composant**
Créer `frontend/app/components/common/CustomFieldNameInput.vue` :
```vue
<template>
<SearchSelect
:model-value="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
:size="size"
@update:model-value="onUpdate"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchSelect from './SearchSelect.vue'
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
placeholder: 'Nom du champ',
size: 'xs',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
function ensureLoaded(): void {
void load()
}
function onUpdate(value: string | number): void {
emit('update:modelValue', String(value ?? ''))
}
</script>
```
> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement.
- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`**
Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre :
```js
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
function handleFocus () {
openDropdown.value = true
if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
emit('focus')
}
```
Ajouter `'focus'` à la liste des emits si pas déjà présent.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 4: Vérifier l'auto-import Nuxt**
Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build :
```bash
cd frontend && npm run dev
```
Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes.
- [ ] **Step 5: Commit**
```bash
git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue
git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
Suggestions, charge la liste au focus. Permet de remplacer un simple
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
dans les editeurs de champs perso."
```
---
## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue`
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="sm"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel rapide**
Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input :
1. Affiche un dropdown au focus avec les noms existants
2. Filtre quand on tape
3. Sélection d'une suggestion → input rempli
4. Texte libre + blur → garde le texte tapé
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor"
```
---
## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue`
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 :
```vue
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
/>
```
⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API).
Remplacer par :
```vue
<CustomFieldNameInput
:model-value="field.name"
placeholder="Nom du champ"
size="sm"
@update:model-value="(value) => handleDefinitionUpdate(field, 'name', value)"
/>
```
> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple.
- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`**
Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API.
```bash
grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue
```
Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 4: Test manuel**
Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network).
- [ ] **Step 5: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldsCard.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard"
```
---
## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue`
**Files:**
- Modify: `frontend/app/components/PieceModelStructureEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel**
Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown.
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/PieceModelStructureEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor"
```
---
## Task 9: Frontend — Migrer `StructureNodeEditor.vue`
**Files:**
- Modify: `frontend/app/components/StructureNodeEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel**
Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown.
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/StructureNodeEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor"
```
---
## Task 10: Frontend — Invalidation du cache après save
**Files:**
- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts`
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue`
- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`**
```bash
grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20
```
Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.).
- [ ] **Step 2: Ajouter l'invalidation après save**
Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants :
```ts
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
```
Dans le corps du composable, ajouter (à placer près des autres `use*` calls) :
```ts
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
```
Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler :
```ts
invalidateCustomFieldNames()
```
> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum.
- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`**
```bash
grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20
```
- [ ] **Step 4: Ajouter l'invalidation après save**
Dans le `<script setup>` de `ModelTypeForm.vue` :
```ts
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
```
Puis, après la sauvegarde réussie du ModelType (typiquement après le `await api(...)` qui POST/PATCH `/api/model_types/...`) :
```ts
invalidateCustomFieldNames()
```
- [ ] **Step 5: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 6: Test manuel**
Scénario :
1. Ouvrir une machine, ajouter un champ perso « Test invalidation 2026 » et save.
2. Ouvrir une autre machine ou un ModelType.
3. Tenter d'ajouter un champ perso → taper « Test invalid » → vérifier que « Test invalidation 2026 » apparaît dans les suggestions.
- [ ] **Step 7: Commit**
```bash
git add frontend/app/composables/useMachineCustomFieldDefs.ts frontend/app/components/model-types/ModelTypeForm.vue
git commit -m "feat(custom-fields) : invalide le cache de suggestions apres save
Apres chaque save reussi de champs perso (machine ou ModelType), on
invalide le cache useCustomFieldNameSuggestions pour que les noms
nouvellement crees apparaissent dans les futures autocomplete."
```
---
## Task 11: Validation finale
**Files:** aucun changement, juste vérification end-to-end.
- [ ] **Step 1: Lancer le typecheck complet**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 2: Lancer le linter complet**
```bash
cd frontend && npm run lint:fix
```
Attendu : 0 errors (ou seulement des fixes auto).
- [ ] **Step 3: Test end-to-end manuel**
Démarrer l'environnement local (`make start` si pas déjà fait), puis :
1. **Machine** : créer une machine, ajouter 2 champs perso « Numéro de série » et « Tension ». Save.
2. **ModelType** : créer un ModelType de catégorie composant, ajouter une pièce dans le skeleton, ajouter à cette pièce un champ perso. Vérifier que « Numéro de série » et « Tension » apparaissent dans les suggestions.
3. **Structure** : créer un ModelType de catégorie machine, naviguer dans la structure, ajouter un champ perso à un composant. Vérifier les suggestions.
4. **Création libre** : taper un nom inédit, voir la ligne « Créer ... », cliquer ou faire blur → garder le texte.
5. **Sélection** : cliquer sur une suggestion → input se remplit avec le nom exact.
- [ ] **Step 4: Vérifier le commit log**
```bash
git log --oneline -15
```
Confirmer qu'on a bien 1 commit par task, avec des messages cohérents.
- [ ] **Step 5: Push (optionnel, à confirmer avec l'utilisateur)**
```bash
git push
```
⚠️ Ne PAS push sans demande explicite de l'utilisateur.
---
## Récapitulatif des fichiers
### Créés
- `src/Controller/CustomFieldNamesController.php`
- `tests/Api/Controller/CustomFieldNamesControllerTest.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Modifiés
- `frontend/app/components/common/SearchSelect.vue`
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- `frontend/app/components/PieceModelStructureEditor.vue`
- `frontend/app/components/StructureNodeEditor.vue`
- `frontend/app/composables/useMachineCustomFieldDefs.ts`
- `frontend/app/components/model-types/ModelTypeForm.vue`

View File

@@ -0,0 +1,273 @@
# Custom Field Name Autocomplete — Design
**Date** : 2026-05-11
**Statut** : Design validé, prêt pour planification
## Contexte et problème
Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
- Au niveau d'une **machine** (entité `CustomField` avec FK `machineId`)
- Au niveau d'un **ModelType**, dans 3 contextes (composant / pièce / produit) : entité `CustomField` avec respectivement `typeComposantId`, `typePieceId`, `typeProductId`.
Côté frontend, l'éditeur de structure d'un ModelType expose des `customFields` array sur chaque node, mais lors du save le backend (`SkeletonStructureService::updateCustomFields`) traduit ça en entités `CustomField` persistées dans la table unique `custom_fields`. La table `custom_fields` est donc **l'unique source de vérité** pour tous les noms de champs perso de l'application.
À chaque création/modification, l'utilisateur saisit librement un **nom** dans un `<input>` texte. Conséquence : les mêmes concepts métier finissent écrits différemment (« Numéro de série », « N° série », « Num serie »), ce qui empêche toute uniformisation et complique les rapports/recherches.
**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
## Décisions clés
| Question | Choix retenu |
|----------|--------------|
| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
## Architecture
```
Backend Frontend
───────── ─────────
GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
│ │ (cache module-level)
│ returns: ["Numéro...", ...] │
▼ ▼
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
FROM custom_fields │
│ utilise
SearchSelect.vue (creatable=true)
│ utilisé par
┌───────────────┼─────────────────────┐
│ │ │
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
Editor (composants) Editor (pièces)
MachineCustomFieldsCard
(édition inline d'une machine)
```
## Backend
### Nouveau endpoint : `GET /api/custom-fields/names`
**Fichier** : `src/Controller/CustomFieldNamesController.php`
**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
```json
["Numéro de série", "Puissance", "Tension nominale"]
```
> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
### Implémentation SQL
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
```sql
SELECT DISTINCT name FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
```
> Toutes les sources de noms (machines, ModelType×composant/pièce/produit) convergent dans la même table `custom_fields` via les FKs `machineId`/`typeComposantId`/`typePieceId`/`typeProductId`. Pas de jointure ni de parsing JSON nécessaire — un simple `SELECT DISTINCT` suffit.
### Pas de cache HTTP
La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
## Frontend
### 1. Extension de `SearchSelect.vue`
**Nouveau prop** :
```js
creatable: {
type: Boolean,
default: false // strict par défaut → zéro régression sur les 10+ usages actuels
}
```
**Changements de comportement quand `creatable=true`** :
| Aspect | Mode strict (défaut) | Mode `creatable` |
|--------|---------------------|------------------|
| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
### 2. Composable `useCustomFieldNameSuggestions`
**Fichier** : `frontend/app/composables/useCustomFieldNameSuggestions.ts`
```ts
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
interface Deps {
api: ReturnType<typeof useApi>
}
export function useCustomFieldNameSuggestions(deps: Deps) {
const { api } = deps
async function load(force = false) {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
cache.value = await api<string[]>('/api/custom-fields/names')
return cache.value
} finally {
loading.value = false
}
}
function invalidate() {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
**Choix de design** :
- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
- **Lazy load** au 1er focus → pas de surcoût au démarrage.
- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
### 3. Composant wrapper `CustomFieldNameInput.vue`
**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
```vue
<template>
<SearchSelect
v-model="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
size="xs"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import SearchSelect from './SearchSelect.vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
const ensureLoaded = () => load()
</script>
```
**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
### 4. Migration des éditeurs
Dans chacun des fichiers ci-dessous, **remplacer le `<input v-model="field.name">`** par :
```vue
<CustomFieldNameInput v-model="field.name" placeholder="Nom du champ" />
```
Fichiers concernés :
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
### 5. Invalidation du cache
Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
| Endroit | Quand |
|---------|-------|
| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
Pattern :
```ts
const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
async function save() {
await api(...) // sauvegarde existante
invalidate() // ← nouveau
}
```
> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
## Comportement utilisateur
### Cas 1 — Création d'un nouveau champ
1. User clique « Ajouter un champ »
2. Un input vide apparaît
3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
### Cas 2 — Modification d'un nom existant
1. User voit un champ existant nommé « Numéro de série »
2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
4. Pas de fusion automatique des données — chaque champ reste indépendant
### Cas 3 — Plusieurs inputs visibles en même temps
- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
## Hors-scope
- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
## Fichiers impactés (résumé)
### Nouveaux fichiers
- `src/Controller/CustomFieldNamesController.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Fichiers modifiés
- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)

View File

@@ -69,9 +69,25 @@
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
v-if="componentConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span
@@ -85,17 +101,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div>
</div>

View File

@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import {
type ConstructeurSummary,
constructeurPhones,
formatConstructeurContact,
resolveConstructeurs,
uniqueConstructeurIds,
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term))
|| constructeurPhones(option).some(t => t.numero.toLowerCase().includes(term))
)
})
@@ -293,14 +294,14 @@ const handleCreate = async () => {
}
creating.value = true
const payload: { name: string; email?: string; phone?: string } = {
const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
name: trimmedName,
}
if (createForm.value.email) {
payload.email = createForm.value.email
}
if (createForm.value.phone) {
payload.phone = createForm.value.phone
if (createForm.value.phone && createForm.value.phone.trim()) {
payload.telephones = [{ numero: createForm.value.phone.trim() }]
}
const result = await createConstructeur(payload)
creating.value = false

View File

@@ -71,9 +71,25 @@
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
</div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
@@ -90,17 +106,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div>
</div>

View File

@@ -94,12 +94,11 @@
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
<CustomFieldNameInput
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
size="xs"
/>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">
Texte

View File

@@ -103,11 +103,10 @@
</div>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
<CustomFieldNameInput
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
size="xs"
/>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>

View File

@@ -0,0 +1,43 @@
<template>
<SearchSelect
:model-value="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
:size="size"
@update:model-value="onUpdate"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchSelect from './SearchSelect.vue'
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
placeholder: 'Nom du champ',
size: 'xs',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { suggestions, load } = useCustomFieldNameSuggestions()
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
function ensureLoaded(): void {
void load()
}
function onUpdate(value: string | number): void {
emit('update:modelValue', String(value ?? ''))
}
</script>

View File

@@ -77,6 +77,15 @@
</button>
</li>
</ul>
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
</div>
</transition>
</div>
@@ -87,6 +96,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
const props = defineProps({
modelValue: {
@@ -137,10 +147,14 @@ const props = defineProps({
serverSearch: {
type: Boolean,
default: false
},
creatable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'search'])
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
const searchTerm = ref('')
const openDropdown = ref(false)
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
return filtered
})
const creatableSuggestion = computed(() => {
if (!props.creatable) return null
const term = searchTerm.value.trim()
if (!term) return null
// Show "Créer ..." only if no option matches exactly (case-insensitive)
const exists = baseOptions.value.some(option => {
const label = resolveLabel(option).toLowerCase()
return label === term.toLowerCase()
})
return exists ? null : term
})
const inputClasses = computed(() => {
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr]
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
watch(
() => props.modelValue,
() => {
if (props.creatable) {
if (searchTerm.value !== props.modelValue) {
searchTerm.value = String(props.modelValue ?? '')
}
return
}
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
@@ -269,6 +301,7 @@ function handleFocus () {
if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
emit('focus')
}
function toggleDropdown () {
@@ -285,6 +318,9 @@ function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
if (props.creatable) {
emit('update:modelValue', searchTerm.value)
}
emit('search', searchTerm.value)
}
@@ -294,8 +330,18 @@ function clearSelection () {
openDropdown.value = false
}
function confirmCreatable () {
if (creatableSuggestion.value) {
emit('update:modelValue', creatableSuggestion.value)
}
openDropdown.value = false
}
function closeDropdown () {
openDropdown.value = false
if (props.creatable) {
return // keep the typed text as-is
}
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
@@ -342,7 +388,11 @@ const handleGlobalClick = (event) => {
onMounted(() => {
window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
if (props.creatable) {
searchTerm.value = String(props.modelValue ?? '')
} else {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
})
onBeforeUnmount(() => {

View File

@@ -0,0 +1,153 @@
<template>
<div class="constructeur-categorie-select space-y-2">
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
<span v-if="!selected.length" class="text-sm text-base-content/50">
Aucune catégorie
</span>
<span
v-for="cat in selected"
:key="cat.id || cat.name"
class="badge badge-outline badge-lg gap-1"
>
<span>{{ cat.name }}</span>
<button
v-if="!disabled"
type="button"
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
aria-label="Retirer la catégorie"
@click="removeCategory(cat)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div>
<div v-if="!disabled" class="relative">
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm md:input-md w-full"
:placeholder="placeholder"
@focus="open = true; ensureLoaded()"
@keydown.escape="open = false"
>
<div
v-if="open && (matches.length || canCreate)"
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
>
<button
v-for="cat in matches"
:key="cat.id"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
@click="addCategory(cat)"
>
{{ cat.name }}
</button>
<button
v-if="canCreate"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
@click="createAndAdd"
>
+ Créer « {{ searchTerm.trim() }} »
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurCategorie[]>,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Rechercher ou créer une catégorie…',
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
}>()
const { categories, loadCategories, createCategory } = useConstructeurCategories()
const searchTerm = ref('')
const open = ref(false)
const loadedOnce = ref(false)
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
const matches = computed<ConstructeurCategorie[]>(() => {
const term = searchTerm.value.trim().toLowerCase()
return categories.value
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
.slice(0, 50)
})
const canCreate = computed(() => {
const term = searchTerm.value.trim()
if (!term) {
return false
}
const lower = term.toLowerCase()
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
&& !selectedKeys.value.has(lower)
})
const ensureLoaded = async () => {
if (loadedOnce.value) {
return
}
loadedOnce.value = true
await loadCategories()
}
const emitSelection = (value: ConstructeurCategorie[]) => {
emit('update:modelValue', value)
}
const addCategory = (cat: ConstructeurCategorie) => {
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
return
}
emitSelection([...selected.value, cat])
searchTerm.value = ''
}
const removeCategory = (cat: ConstructeurCategorie) => {
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
}
const createAndAdd = async () => {
const created = await createCategory(searchTerm.value)
if (created) {
addCategory(created)
}
}
const onDocumentClick = (event: Event) => {
const target = event.target as HTMLElement | null
if (target && !target.closest('.constructeur-categorie-select')) {
open.value = false
}
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
</script>

View File

@@ -33,12 +33,11 @@
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
<CustomFieldNameInput
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
size="sm"
/>
<select v-model="field.type" class="select select-bordered select-sm">
<option value="text">
Texte

View File

@@ -50,12 +50,11 @@
<div class="flex-1 space-y-2">
<!-- Definition fields -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
<CustomFieldNameInput
:model-value="field.name"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
size="sm"
@update:model-value="(value: string) => handleDefinitionUpdate(field, 'name', value)"
/>
<select
:value="field.type || 'text'"

View File

@@ -204,7 +204,7 @@ const formulaBuilderCustomFields = computed(() => {
const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
}

View File

@@ -91,7 +91,7 @@ const preview = computed(() => {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
}
}
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
})
const insertField = (fieldName: string) => {

View File

@@ -0,0 +1,63 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface ConstructeurCategorie {
'@id'?: string
id: string
name: string
}
const categories = ref<ConstructeurCategorie[]>([])
const loading = ref(false)
const loaded = ref(false)
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
export function useConstructeurCategories() {
const { get, post } = useApi()
const { showError } = useToast()
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
if (loaded.value && !force) {
return categories.value
}
loading.value = true
try {
const result = await get('/constructeur_categories?itemsPerPage=1000')
if (result.success) {
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
loaded.value = true
}
return categories.value
}
finally {
loading.value = false
}
}
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
const trimmed = name.trim()
if (!trimmed) {
return null
}
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
if (existing) {
return existing
}
const result = await post('/constructeur_categories', { name: trimmed })
if (result.success && result.data && !Array.isArray(result.data)) {
const created = result.data as ConstructeurCategorie
categories.value = sortByName([...categories.value, created])
return created
}
if (result.error) {
showError(result.error)
}
return null
}
return { categories, loading, loadCategories, createCategory }
}

View File

@@ -1,13 +1,30 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone {
'@id'?: string
id?: string
numero: string
label?: string | null
}
export interface ConstructeurCategorieRef {
'@id'?: string
id: string
name: string
}
export interface Constructeur {
'@id'?: string
id: string
name: string
email?: string | null
phone?: string | null
telephones?: ConstructeurTelephone[]
categories?: ConstructeurCategorieRef[]
createdAt?: string
updatedAt?: string
}
interface ConstructeurResult {
@@ -16,6 +33,24 @@ interface ConstructeurResult {
error?: string
}
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false)
const loaded = ref(false)
@@ -66,8 +101,10 @@ export function useConstructeurs() {
}
loading.value = true
try {
const query = search ? `?search=${encodeURIComponent(search)}` : ''
const result = await get(`/constructeurs${query}`)
const params = new URLSearchParams()
params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
@@ -87,7 +124,38 @@ export function useConstructeurs() {
return loadConstructeurs(search)
}
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await post('/constructeurs', data)
@@ -161,7 +229,7 @@ export function useConstructeurs() {
.filter((item): item is Constructeur => item !== null)
}
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await patch(`/constructeurs/${id}`, data)
@@ -210,6 +278,7 @@ export function useConstructeurs() {
loading,
loadConstructeurs,
searchConstructeurs,
fetchConstructeursPage,
createConstructeur,
updateConstructeur,
deleteConstructeur,

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
export function useCustomFieldNameSuggestions() {
const api = useApi()
async function load(force = false): Promise<string[]> {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
const response = await api.get<string[]>('/custom-fields/names')
if (response.success && Array.isArray(response.data)) {
cache.value = response.data
}
else {
cache.value = cache.value ?? []
if (response.error) {
console.error('[useCustomFieldNameSuggestions] load failed:', response.error)
}
}
return cache.value
}
finally {
loading.value = false
}
}
function invalidate(): void {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}

View File

@@ -7,6 +7,7 @@
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
import { humanizeError } from '~/shared/utils/errorMessages'
import {
listModelTypes,
@@ -79,6 +80,7 @@ export function invalidateEntityTypeCache(category: ModelCategory) {
export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config
const { showSuccess, showError } = useToast()
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
const state = getOrCreateState(category)
const normalizeItem = (item: ModelType): EntityType => ({
@@ -124,6 +126,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
})
const normalized = normalizeItem(data)
state.types.value.push(normalized)
invalidateCustomFieldNames()
showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
@@ -150,6 +153,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
const normalized = normalizeItem(data)
const index = state.types.value.findIndex((t) => t.id === id)
if (index !== -1) state.types.value[index] = normalized
invalidateCustomFieldNames()
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized }
} catch (error) {

View File

@@ -1,5 +1,6 @@
import { reactive, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
import { useToast } from '~/composables/useToast'
// --- Types ---
@@ -88,6 +89,7 @@ const parseOptions = (optionsText: string): string[] =>
export function useMachineCustomFieldDefs(deps: Deps) {
const { apiCall } = useApi()
const { showSuccess, showError } = useToast()
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
// --- State ---
@@ -294,6 +296,7 @@ export function useMachineCustomFieldDefs(deps: Deps) {
}
showSuccess('Champs personnalisés sauvegardés avec succès')
invalidateCustomFieldNames()
await deps.onSaved()
} catch {
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')

View File

@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.')
}
} catch (error) {
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))

View File

@@ -6,7 +6,7 @@
Fournisseurs
</h1>
<p class="text-sm text-gray-500">
Gérez les fournisseurs et leurs coordonnées.
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
</p>
</div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
@@ -19,29 +19,69 @@
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="filteredConstructeurs"
:rows="pageItems"
:loading="loading"
:sort="currentSort"
:show-counter="false"
:pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé."
@sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
/>
</label>
<div class="flex flex-col sm:flex-row gap-3 w-full">
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
>
</label>
<label class="w-full sm:w-64">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
<select
v-model="selectedCategoryId"
class="select select-bordered select-sm w-full mt-1"
>
<option value="">
Toutes les catégories
</option>
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</label>
</div>
</template>
<template #cell-phone="{ row }">
{{ formatPhoneDisplay(row.phone) }}
<template #cell-telephones="{ row }">
<div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
{{ formatPhoneDisplay(tel.numero) }}
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-categories="{ row }">
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
<span
v-for="cat in row.categories"
:key="cat.id"
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
@click="selectedCategoryId = cat.id"
>
{{ cat.name }}
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-createdAt="{ row }">
@@ -96,7 +136,7 @@
</div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3>
@@ -105,10 +145,53 @@
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<div class="form-control">
<div class="flex items-center justify-between mb-1">
<span class="label-text">Téléphones</span>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="addTelephoneRow"
>
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
Aucun téléphone.
</p>
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
<div class="flex-1">
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
</div>
<input
v-model="tel.label"
type="text"
class="input input-bordered input-sm md:input-md w-40"
placeholder="Libellé (optionnel)"
:disabled="!canEdit"
>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-sm text-error"
aria-label="Supprimer ce téléphone"
@click="removeTelephoneRow(idx)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Catégories</span></label>
<ConstructeurCategorieSelect v-model="form.categories" :disabled="!canEdit" />
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeModal">
Annuler
@@ -125,26 +208,47 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { constructeurPhones } from '~/shared/constructeurUtils'
import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
interface ConstructeurFormState {
name: string
email: string
telephones: TelephoneFormRow[]
categories: ConstructeurCategorie[]
}
const api = useApi()
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'telephones', label: 'Téléphones' },
{ key: 'categories', label: 'Catégories' },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
@@ -153,9 +257,10 @@ const columns = [
]
const searchTerm = ref('')
const selectedCategoryId = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const stats = ref({})
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
const currentSort = computed(() => ({
field: sortKey.value,
@@ -167,40 +272,80 @@ const handleSort = (sort) => {
sortDir.value = sort.direction
}
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false)
const saving = ref(false)
const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' })
const editingConstructeur = ref<Record<string, any> | null>(null)
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => {
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
return dir * (a[key] || '').localeCompare(b[key] || '')
})
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
const rowPhones = constructeurPhones
const debouncedSearch = debounce(() => {
currentPage.value = 1
loadPage()
}, 300)
watch(selectedCategoryId, () => {
currentPage.value = 1
loadPage()
})
const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
watch([sortKey, sortDir], () => {
currentPage.value = 1
loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
function debounce(fn, delay) {
let timeout
@@ -211,7 +356,7 @@ function debounce(fn, delay) {
}
const resetForm = () => {
form.value = { name: '', email: '', phone: '' }
form.value = { name: '', email: '', telephones: [], categories: [] }
editingConstructeur.value = null
}
@@ -225,7 +370,12 @@ const openEditModal = (constructeur) => {
form.value = {
name: constructeur.name,
email: constructeur.email || '',
phone: constructeur.phone || '',
telephones: (constructeur.telephones || []).map(t => ({
'@id': t['@id'],
numero: t.numero || '',
label: t.label || '',
})),
categories: (constructeur.categories || []).map(c => ({ ...c })),
}
modalOpen.value = true
}
@@ -235,8 +385,20 @@ const closeModal = () => {
resetForm()
}
const addTelephoneRow = () => {
form.value.telephones.push({ numero: '', label: '' })
}
const removeTelephoneRow = (idx) => {
form.value.telephones.splice(idx, 1)
}
const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
if (!trimmedName) {
showError('Le nom est obligatoire.')
return
}
const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
@@ -247,9 +409,24 @@ const saveConstructeur = async () => {
}
saving.value = true
const payload = { ...form.value, name: trimmedName }
if (!payload.email) { delete payload.email }
if (!payload.phone) { delete payload.phone }
const payload = {
name: trimmedName,
email: form.value.email?.trim() || null,
telephones: form.value.telephones
.filter(t => t.numero && t.numero.trim())
.map((t) => {
const entry: { numero: string, label: string | null, '@id'?: string } = {
numero: t.numero.trim(),
label: t.label?.trim() || null,
}
if (t['@id']) { entry['@id'] = t['@id'] }
return entry
}),
categories: form.value.categories
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
.filter((iri): iri is string => Boolean(iri)),
}
let result
if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload)
@@ -260,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false
if (result.success) {
closeModal()
await searchConstructeurs(searchTerm.value)
await loadPage()
}
}
@@ -271,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) {
showError(result.error)
return
}
if (result.success) {
await loadPage()
}
}
@@ -282,7 +463,8 @@ const loadStats = async () => {
}
onMounted(() => {
loadConstructeurs()
loadPage()
loadCategories()
loadStats()
})
</script>

View File

@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.')
}
} catch (error) {
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications,
})
await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))

View File

@@ -1,12 +1,49 @@
import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurTelephoneSummary {
numero?: string | null;
label?: string | null;
}
export interface ConstructeurSummary {
id: string;
name?: string | null;
email?: string | null;
// Legacy single-phone string: still exposed by the machine-structure normalization.
phone?: string | null;
// Multi-phone list: exposed by the /constructeurs API resource.
telephones?: ConstructeurTelephoneSummary[] | null;
}
type ConstructeurPhoneSource = {
phone?: string | null;
telephones?: ConstructeurTelephoneSummary[] | null;
} | null | undefined;
export const constructeurPhones = (
constructeur: ConstructeurPhoneSource,
): Array<{ numero: string; label: string | null }> => {
if (!constructeur) {
return [];
}
const list = Array.isArray(constructeur.telephones)
? constructeur.telephones
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
: [];
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
return [{ numero: constructeur.phone.trim(), label: null }];
}
return list;
};
export const constructeurPrimaryPhone = (
constructeur: ConstructeurPhoneSource,
): string | null => {
const phones = constructeurPhones(constructeur);
return phones.length ? phones[0]!.numero : null;
};
export interface ConstructeurLinkEntry {
linkId?: string;
constructeurId: string;
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
return '';
}
const formattedPhone = formatPhone(constructeur.phone);
const phone = formattedPhone || constructeur.phone || null;
const primary = constructeurPrimaryPhone(constructeur);
const phone = formatPhone(primary) || primary || null;
return [constructeur.email, phone].filter(Boolean).join(' • ');
};

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -206,12 +212,6 @@ vi.mock('~/shared/constructeurUtils', () => ({
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -8,6 +8,12 @@ import {
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -222,12 +228,6 @@ vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Test data — component with structure containing slots
// ---------------------------------------------------------------------------

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -40,12 +46,6 @@ vi.mock('~/composables/useToast', () => ({
}),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Mock data — realistic /machines/{id}/structure response
// ---------------------------------------------------------------------------
@@ -345,12 +351,6 @@ vi.mock('~/shared/utils/documentDisplayUtils', () => ({
downloadDocument: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------

View File

@@ -9,6 +9,12 @@ import {
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -183,12 +189,6 @@ vi.mock('~/shared/apiRelations', () => ({
},
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260512150000_AddConstructeurCategoriesAndPhones extends AbstractMigration
{
public function getDescription(): string
{
return 'Add constructeur_categorie + constructeur_categories (M2M) + constructeur_telephone (1-N); migrate constructeurs.phone into constructeur_telephone then drop the phone column';
}
public function up(Schema $schema): void
{
// 1. Référentiel de catégories de fournisseurs.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categorie (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// 2. Table de jointure many-to-many fournisseur <-> catégorie.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categories (
constructeur_id VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
categorie_id VARCHAR(36) NOT NULL REFERENCES constructeur_categorie(id) ON DELETE CASCADE,
PRIMARY KEY(constructeur_id, categorie_id)
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_categories_categorie ON constructeur_categories (categorie_id)');
// 3. Téléphones (un fournisseur peut en avoir plusieurs).
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_telephone (
id VARCHAR(36) NOT NULL PRIMARY KEY,
constructeurid VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
numero VARCHAR(50) NOT NULL,
label VARCHAR(100) DEFAULT NULL,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_telephone_constructeur ON constructeur_telephone (constructeurid)');
// 4. Migration des téléphones existants (colonne unique) vers la nouvelle table.
$this->addSql(<<<'SQL'
INSERT INTO constructeur_telephone (id, constructeurid, numero, label, createdat, updatedat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text || c.id), 1, 24),
c.id,
trim(c.phone),
NULL,
NOW(),
NOW()
FROM constructeurs c
WHERE c.phone IS NOT NULL AND trim(c.phone) <> ''
SQL);
// 5. La colonne unique n'est plus la source de vérité.
$this->addSql('ALTER TABLE constructeurs DROP COLUMN IF EXISTS phone');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE constructeurs ADD COLUMN IF NOT EXISTS phone VARCHAR(255) DEFAULT NULL');
// Restaure un téléphone par fournisseur (le plus récemment créé), best-effort.
$this->addSql(<<<'SQL'
UPDATE constructeurs c
SET phone = t.numero
FROM (
SELECT DISTINCT ON (constructeurid) constructeurid, numero
FROM constructeur_telephone
ORDER BY constructeurid, createdat DESC
) t
WHERE t.constructeurid = c.id
SQL);
$this->addSql('DROP TABLE IF EXISTS constructeur_telephone');
$this->addSql('DROP TABLE IF EXISTS constructeur_categories');
$this->addSql('DROP TABLE IF EXISTS constructeur_categorie');
}
}

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\EntityManagerInterface;
use SplObjectStorage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Importe un référentiel de fournisseurs depuis un fichier JSON de la forme
* {"count": N, "data": [{"reference": "...", "name": "...", "categoriesStr": "a, b", "organizationsStr": "...", "phone": "..."}, ...]}.
*
* Règles : on garde l'existant. Si un fournisseur du fichier porte le même nom (insensible à la casse/aux espaces)
* qu'un fournisseur déjà en base, on le complète sans changer son id : on n'ajoute que les catégories et les
* téléphones manquants, on n'écrase ni ne supprime jamais rien.
*/
#[AsCommand(
name: 'app:import-fournisseurs',
description: 'Importe/complète les fournisseurs depuis un fichier JSON (customer.json par défaut). Dry-run par défaut : utiliser --force pour écrire.',
)]
class ImportFournisseursCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::OPTIONAL, 'Chemin du fichier JSON', 'customer.json')
->addOption('force', null, InputOption::VALUE_NONE, 'Écrit réellement en base (sinon dry-run)')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Ne traiter que les N premières entrées (debug)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$write = (bool) $input->getOption('force');
$limit = null !== $input->getOption('limit') ? max(0, (int) $input->getOption('limit')) : null;
$path = (string) $input->getArgument('file');
if (!str_starts_with($path, '/')) {
$path = rtrim($this->projectDir, '/').'/'.$path;
}
if (!is_file($path) || !is_readable($path)) {
$io->error(sprintf('Fichier introuvable ou illisible : %s', $path));
return Command::FAILURE;
}
$raw = file_get_contents($path);
$decoded = json_decode((string) $raw, true);
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
$io->error('JSON invalide : la clé "data" (tableau) est attendue.');
return Command::FAILURE;
}
/** @var array<int, array<string, mixed>> $rows */
$rows = $decoded['data'];
if (null !== $limit) {
$rows = array_slice($rows, 0, $limit);
}
$io->title('Import fournisseurs');
$io->writeln(sprintf('Fichier : <info>%s</info>', $path));
$io->writeln(sprintf('Entrées : <info>%d</info>', count($rows)));
$io->writeln($write ? '<comment>Mode écriture (--force)</comment>' : '<comment>Mode dry-run — aucune écriture. Ajouter --force pour appliquer.</comment>');
$io->newLine();
// --- Chargement des référentiels existants ---------------------------------
/** @var array<string, Constructeur> $constructeursByName */
$constructeursByName = [];
foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) {
$constructeursByName[$this->normalizeKey((string) $c->getName())] = $c;
}
/** @var array<string, ConstructeurCategorie> $categoriesByName */
$categoriesByName = [];
foreach ($this->em->getRepository(ConstructeurCategorie::class)->findAll() as $cat) {
$categoriesByName[$this->normalizeKey((string) $cat->getName())] = $cat;
}
// numéros et liens catégorie déjà présents, indexés par objet Constructeur
$seenNumeros = new SplObjectStorage(); // Constructeur => array<string,true> (clé = numéro normalisé)
$seenCatLinks = new SplObjectStorage(); // Constructeur => array<string,true> (clé = nom catégorie normalisé)
// pré-remplissage pour les fournisseurs existants
$existingTel = $this->em->getRepository(ConstructeurTelephone::class)->findAll();
foreach ($existingTel as $tel) {
$owner = $tel->getConstructeur();
if (null === $owner) {
continue;
}
$map = $seenNumeros[$owner] ?? [];
$map[$this->normalizeKey((string) $tel->getNumero())] = true;
$seenNumeros[$owner] = $map;
}
/** @var array<int, array{cname: string, catname: string}> $catLinkPairs */
$catLinkPairs = $this->em->createQuery(
'SELECT c.name AS cname, cat.name AS catname FROM '.Constructeur::class.' c JOIN c.categories cat'
)->getArrayResult();
foreach ($catLinkPairs as $pair) {
$cKey = $this->normalizeKey((string) $pair['cname']);
$catKey = $this->normalizeKey((string) $pair['catname']);
$owner = $constructeursByName[$cKey] ?? null;
if (null === $owner) {
continue;
}
$map = $seenCatLinks[$owner] ?? [];
$map[$catKey] = true;
$seenCatLinks[$owner] = $map;
}
// --- Traitement ------------------------------------------------------------
$created = 0;
$matched = 0;
$phonesAdded = 0;
$categoriesCreated = 0;
$catLinksAdded = 0;
$skippedNoName = 0;
$tooLong = [];
$i = 0;
foreach ($rows as $row) {
++$i;
$name = trim((string) ($row['name'] ?? $row['reference'] ?? ''));
if ('' === $name) {
++$skippedNoName;
continue;
}
if (mb_strlen($name) > 255) {
$tooLong[] = $name;
$name = mb_substr($name, 0, 255);
}
$key = $this->normalizeKey($name);
if (isset($constructeursByName[$key])) {
$constructeur = $constructeursByName[$key];
++$matched;
} else {
$constructeur = new Constructeur()->setName($name);
if ($write) {
$this->em->persist($constructeur);
}
$constructeursByName[$key] = $constructeur;
++$created;
}
// --- téléphones ---
foreach ($this->splitPhones((string) ($row['phone'] ?? '')) as $numero) {
$numero = mb_substr($numero, 0, 50);
$nKey = $this->normalizeKey($numero);
$map = $seenNumeros[$constructeur] ?? [];
if (isset($map[$nKey])) {
continue;
}
$tel = new ConstructeurTelephone()->setNumero($numero);
$constructeur->addTelephone($tel);
if ($write) {
$this->em->persist($tel);
}
$map[$nKey] = true;
$seenNumeros[$constructeur] = $map;
++$phonesAdded;
}
// --- catégories ---
foreach ($this->splitCategories((string) ($row['categoriesStr'] ?? '')) as $catName) {
$catName = mb_substr($catName, 0, 255);
$catKey = $this->normalizeKey($catName);
if (isset($categoriesByName[$catKey])) {
$categorie = $categoriesByName[$catKey];
} else {
$categorie = new ConstructeurCategorie()->setName($catName);
if ($write) {
$this->em->persist($categorie);
}
$categoriesByName[$catKey] = $categorie;
++$categoriesCreated;
}
$linkMap = $seenCatLinks[$constructeur] ?? [];
if (isset($linkMap[$catKey])) {
continue;
}
$constructeur->addCategory($categorie);
$linkMap[$catKey] = true;
$seenCatLinks[$constructeur] = $linkMap;
++$catLinksAdded;
}
if ($write && 0 === $i % 200) {
$this->em->flush();
}
}
if ($write) {
$this->em->flush();
}
// --- Rapport ---------------------------------------------------------------
$io->section('Résultat');
$io->table(
['Action', 'Nombre'],
[
['Fournisseurs créés', $created],
['Fournisseurs déjà en base (complétés si besoin)', $matched],
['Téléphones ajoutés', $phonesAdded],
['Catégories créées', $categoriesCreated],
['Liens fournisseur↔catégorie ajoutés', $catLinksAdded],
['Entrées ignorées (sans nom)', $skippedNoName],
['Noms tronqués (>255)', count($tooLong)],
]
);
if ($tooLong) {
$io->warning(sprintf('%d nom(s) dépassaient 255 caractères et ont été tronqués.', count($tooLong)));
}
if ($write) {
$io->success('Import terminé.');
} else {
$io->note('Dry-run : rien n\'a été écrit. Relancer avec --force pour appliquer.');
}
return Command::SUCCESS;
}
/**
* @return list<string>
*/
private function splitPhones(string $value): array
{
$parts = preg_split('#[/;\n\r]+#', $value) ?: [];
$out = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' !== $p) {
$out[] = $p;
}
}
return array_values(array_unique($out));
}
/**
* @return list<string>
*/
private function splitCategories(string $value): array
{
$parts = explode(',', $value);
$out = [];
$seen = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' === $p) {
continue;
}
$k = $this->normalizeKey($p);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $p;
}
return $out;
}
private function normalizeKey(string $value): string
{
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $value) ?? $value));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
final class CustomFieldNamesController
{
public function __construct(private readonly Connection $connection) {}
#[Route(
path: '/api/custom-fields/names',
name: 'api_custom_fields_names',
methods: ['GET']
)]
#[IsGranted('ROLE_VIEWER')]
public function __invoke(): JsonResponse
{
$sql = <<<'SQL'
SELECT DISTINCT name
FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
SQL;
$names = $this->connection->fetchFirstColumn($sql);
return new JsonResponse($names);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -872,7 +873,7 @@ class MachineStructureController extends AbstractController
'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(),
'phone' => $this->constructeurPhone($link->getConstructeur()),
],
'supplierReference' => $link->getSupplierReference(),
];
@@ -881,6 +882,13 @@ class MachineStructureController extends AbstractController
return $items;
}
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -19,12 +23,16 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [
@@ -36,7 +44,9 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']]
)]
class Constructeur
{
@@ -44,24 +54,43 @@ class Constructeur
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $email = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, ConstructeurTelephone>
*/
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $telephones;
/**
* @var Collection<int, ConstructeurCategorie>
*/
#[ORM\ManyToMany(targetEntity: ConstructeurCategorie::class, inversedBy: 'constructeurs')]
#[ORM\JoinTable(name: 'constructeur_categories')]
#[ORM\JoinColumn(name: 'constructeur_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'categorie_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $categories;
/**
* @var Collection<int, MachineConstructeurLink>
*/
@@ -94,6 +123,8 @@ class Constructeur
$this->composantLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->telephones = new ArrayCollection();
$this->categories = new ArrayCollection();
}
public function getName(): ?string
@@ -120,14 +151,55 @@ class Constructeur
return $this;
}
public function getPhone(): ?string
/**
* @return Collection<int, ConstructeurTelephone>
*/
public function getTelephones(): Collection
{
return $this->phone;
return $this->telephones;
}
public function setPhone(?string $phone): static
public function addTelephone(ConstructeurTelephone $telephone): static
{
$this->phone = $phone;
if (!$this->telephones->contains($telephone)) {
$this->telephones->add($telephone);
$telephone->setConstructeur($this);
}
return $this;
}
public function removeTelephone(ConstructeurTelephone $telephone): static
{
if ($this->telephones->removeElement($telephone)) {
if ($telephone->getConstructeur() === $this) {
$telephone->setConstructeur(null);
}
}
return $this;
}
/**
* @return Collection<int, ConstructeurCategorie>
*/
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(ConstructeurCategorie $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(ConstructeurCategorie $category): static
{
$this->categories->removeElement($category);
return $this;
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurCategorieRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Une catégorie de fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurCategorieRepository::class)]
#[ORM\Table(name: 'constructeur_categorie')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Catégories de fournisseurs (ex. organisme de formation, transporteur, agence d\'intérim). Référentiel partagé : une même catégorie peut être rattachée à plusieurs fournisseurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 1000,
order: ['name' => 'ASC']
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['name'])]
class ConstructeurCategorie
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read'])]
private ?string $name = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, mappedBy: 'categories')]
private Collection $constructeurs;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurTelephoneRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ConstructeurTelephoneRepository::class)]
#[ORM\Table(name: 'constructeur_telephone')]
#[ORM\Index(name: 'idx_constructeur_telephone_constructeur', columns: ['constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Numéro de téléphone rattaché à un fournisseur. Un fournisseur peut en avoir plusieurs (standard, mobile, comptabilité…).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['constructeur' => 'exact'])]
class ConstructeurTelephone
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'telephones')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Constructeur $constructeur = null;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Assert\NotBlank(message: 'Le numéro de téléphone est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $numero = null;
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $label = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getConstructeur(): ?Constructeur
{
return $this->constructeur;
}
public function setConstructeur(?Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
}

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)]
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$telephones = $this->safeGet($entity, 'getTelephones');
$categories = $this->safeGet($entity, 'getCategories');
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'),
'phone' => $this->safeGet($entity, 'getPhone'),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'),
'telephones' => $telephones instanceof Collection ? array_values(array_map(
static fn (ConstructeurTelephone $t): array => ['numero' => $t->getNumero(), 'label' => $t->getLabel()],
$telephones->toArray(),
)) : [],
'categories' => $categories instanceof Collection ? array_values(array_filter(array_map(
static fn (ConstructeurCategorie $c): ?string => $c->getName(),
$categories->toArray(),
))) : [],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('telephones');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$queryBuilder
->leftJoin(sprintf('%s.telephones', $alias), $telAlias)
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%4$s OR LOWER(%1$s.email) LIKE :%4$s OR LOWER(%2$s.numero) LIKE :%4$s',
$alias,
$telAlias,
'',
$paramName,
))
->setParameter($paramName, $likePattern)
;
$queryBuilder->groupBy(sprintf('%s.id', $alias));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Entity\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
@@ -34,7 +35,12 @@ class CreateConstructeurTool
$constructeur = new Constructeur();
$constructeur->setName($name);
$constructeur->setEmail('' !== $email ? $email : null);
$constructeur->setPhone('' !== $phone ? $phone : null);
if ('' !== $phone) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
$this->em->persist($constructeur);
$this->em->flush();

View File

@@ -29,13 +29,23 @@ class GetConstructeurTool
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
}
$telephones = array_map(
static fn ($t): array => ['id' => $t->getId(), 'numero' => $t->getNumero(), 'label' => $t->getLabel()],
$constructeur->getTelephones()->toArray(),
);
$categories = array_values(array_filter(array_map(
static fn ($c): ?string => $c->getName(),
$constructeur->getCategories()->toArray(),
)));
return $this->jsonResponse([
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'telephones' => array_values($telephones),
'categories' => $categories,
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}

View File

@@ -30,7 +30,7 @@ class ListConstructeursTool
;
$qb = $this->constructeurs->createQueryBuilder('c')
->select('c.id', 'c.name', 'c.email', 'c.phone')
->select('c.id', 'c.name', 'c.email')
->orderBy('c.name', 'ASC')
;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface;
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_constructeur',
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
description: 'Update an existing constructeur. Only provided fields are changed. A non-empty "phone" is added as an additional phone number if not already present (existing numbers are never removed). Requires ROLE_GESTIONNAIRE.',
)]
class UpdateConstructeurTool
{
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
if (null !== $email) {
$constructeur->setEmail($email);
}
if (null !== $phone) {
$constructeur->setPhone($phone);
if (null !== $phone && '' !== $phone) {
$alreadyPresent = false;
foreach ($constructeur->getTelephones() as $existing) {
if ($existing->getNumero() === $phone) {
$alreadyPresent = true;
break;
}
}
if (!$alreadyPresent) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
}
$this->em->flush();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -364,7 +365,7 @@ class MachineStructureTool
'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(),
'phone' => $this->constructeurPhone($link->getConstructeur()),
],
'supplierReference' => $link->getSupplierReference(),
];
@@ -373,6 +374,13 @@ class MachineStructureTool
return $items;
}
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFields(Collection $customFields): array
{
$items = [];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurCategorie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurCategorie>
*/
class ConstructeurCategorieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurCategorie::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurTelephone>
*/
class ConstructeurTelephoneRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurTelephone::class);
}
}

View File

@@ -32,7 +32,7 @@ class ReferenceAutoGenerator
}
}
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
return preg_replace_callback('/\{([^}]+)\}/u', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula());
}

View File

@@ -226,6 +226,13 @@ class SkeletonStructureService
}
if ($existingField) {
// Propagate rename to the parent ModelType's reference formula and required-fields list
// so existing `{oldName}` placeholders keep resolving after the field is renamed.
$oldName = $existingField->getName();
if ($oldName !== $normalized['name']) {
$this->propagateCustomFieldRename($modelType, $oldName, $normalized['name']);
}
// Update existing field
$existingField->setName($normalized['name']);
$existingField->setType($normalized['type']);
@@ -264,6 +271,38 @@ class SkeletonStructureService
}
}
private function propagateCustomFieldRename(ModelType $modelType, string $oldName, string $newName): void
{
$formula = $modelType->getReferenceFormula();
if (null !== $formula && '' !== $formula) {
$newFormula = preg_replace(
'/\{'.preg_quote($oldName, '/').'\}/',
'{'.$newName.'}',
$formula
);
if (null !== $newFormula && $newFormula !== $formula) {
$modelType->setReferenceFormula($newFormula);
}
}
$required = $modelType->getRequiredFieldsForReference();
if ($required) {
$changed = false;
$newRequired = [];
foreach ($required as $fieldName) {
if ($fieldName === $oldName) {
$newRequired[] = $newName;
$changed = true;
} else {
$newRequired[] = $fieldName;
}
}
if ($changed) {
$modelType->setRequiredFieldsForReference($newRequired);
}
}
}
/**
* Normalize frontend custom field data to a common shape.
*

View File

@@ -12,6 +12,8 @@ use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -250,7 +252,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
$c = new Constructeur();
$c->setName($name);
$c->setEmail($email);
$c->setPhone($phone);
if (null !== $phone) {
$tel = new ConstructeurTelephone();
$tel->setNumero($phone);
$c->addTelephone($tel);
}
$em = $this->getEntityManager();
$em->persist($c);
@@ -259,6 +266,32 @@ abstract class AbstractApiTestCase extends ApiTestCase
return $c;
}
protected function createConstructeurCategorie(string $name = 'Catégorie Test'): ConstructeurCategorie
{
$categorie = new ConstructeurCategorie();
$categorie->setName($name);
$em = $this->getEntityManager();
$em->persist($categorie);
$em->flush();
return $categorie;
}
protected function createConstructeurTelephone(Constructeur $constructeur, string $numero = '0102030405', ?string $label = null): ConstructeurTelephone
{
$tel = new ConstructeurTelephone();
$tel->setConstructeur($constructeur);
$tel->setNumero($numero);
$tel->setLabel($label);
$em = $this->getEntityManager();
$em->persist($tel);
$em->flush();
return $tel;
}
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{
$link = new MachineConstructeurLink();

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseStatusCodeSame(401);
}
public function testReturnsArrayForAuthenticatedViewer(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($data);
}
public function testReturnsDistinctSortedNames(): void
{
$machine1 = $this->createMachine('M1');
$this->createCustomField('Tension', 'text', $machine1);
$this->createCustomField('Numéro de série', 'text', $machine1);
$machine2 = $this->createMachine('M2');
$this->createCustomField('Tension', 'text', $machine2); // doublon
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertContains('Tension', $data);
$this->assertContains('Numéro de série', $data);
// Pas de doublon
$this->assertSame(count(array_unique($data)), count($data));
// Tri alpha
$sorted = $data;
sort($sorted, SORT_STRING);
$this->assertSame($sorted, $data);
}
}

View File

@@ -33,9 +33,9 @@ class ConstructeurTest extends AbstractApiTestCase
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Siemens',
'email' => 'contact@siemens.com',
'phone' => '+33123456789',
'name' => 'Siemens',
'email' => 'contact@siemens.com',
'telephones' => [['numero' => '+33123456789']],
]);
}
@@ -78,11 +78,32 @@ class ConstructeurTest extends AbstractApiTestCase
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['phone' => '+33987654321'],
'json' => ['email' => 'updated@siemens.com'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['phone' => '+33987654321']);
$this->assertJsonContains(['email' => 'updated@siemens.com']);
}
public function testPatchCategories(): void
{
$c = $this->createConstructeur('Siemens');
$cat1 = $this->createConstructeurCategorie('Transporteur');
$cat2 = $this->createConstructeurCategorie('Organisme de formation');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['categories' => [
self::iri('constructeur_categories', $cat1->getId()),
self::iri('constructeur_categories', $cat2->getId()),
]],
]);
$this->assertResponseIsSuccessful();
$client->request('GET', self::iri('constructeurs', $c->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['categories' => [['name' => 'Transporteur'], ['name' => 'Organisme de formation']]]);
}
public function testDelete(): void

View File

@@ -145,6 +145,69 @@ class ReferenceAutoGeneratorTest extends AbstractApiTestCase
self::assertSame('U507', $result);
}
public function testGenerateWithAccentedFieldName(): void
{
$mt = $this->createModelType('Palier', 'PAL-ACCENT', ModelCategory::PIECE);
$mt->setReferenceFormula('PA-{Diamètre}-33');
$mt->setRequiredFieldsForReference(['Diamètre']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('Diamètre', 'number', typePiece: $mt);
$piece = $this->createPiece('Palier 70', null, $mt);
$this->createCustomFieldValue($cf, '70', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('PA-70-33', $result);
}
public function testGenerateWithNumberTypeField(): void
{
$mt = $this->createModelType('NumberField', 'NUM-001', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Number', null, $mt);
$this->createCustomFieldValue($cf, '42', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-42', $result);
}
public function testGenerateWithDecimalNumberField(): void
{
$mt = $this->createModelType('NumberDec', 'NUM-002', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Dec', null, $mt);
$this->createCustomFieldValue($cf, '12.5', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-12.5', $result);
}
public function testGenerateWithSpaceInFormula(): void
{
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Service\SkeletonStructureService;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SkeletonStructureServiceTest extends AbstractApiTestCase
{
public function testRenameCustomFieldPropagatesToReferenceFormulaAndRequiredFields(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-RENAME', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}-{size}');
$mt->setRequiredFieldsForReference(['material', 'size']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfSize = $this->createCustomField('size', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
// Same fields, but `material` is renamed to `materiau` (matched by customFieldId)
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'materiau', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfSize->getId(), 'name' => 'size', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
$em->refresh($cfMaterial);
self::assertSame('materiau', $cfMaterial->getName());
self::assertSame('{materiau}-{size}', $mt->getReferenceFormula());
self::assertSame(['materiau', 'size'], $mt->getRequiredFieldsForReference());
}
public function testRenameLeavesFormulaUnchangedWhenFieldNotInFormula(): void
{
$mt = $this->createModelType('Roulement2', 'ROUL-RENAME2', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}');
$mt->setRequiredFieldsForReference(['material']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfUnused = $this->createCustomField('unused', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'material', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfUnused->getId(), 'name' => 'renamed', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
self::assertSame('{material}', $mt->getReferenceFormula());
self::assertSame(['material'], $mt->getRequiredFieldsForReference());
}
}