Compare commits
31 Commits
v1.9.29
...
d3f269452c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f269452c | ||
|
|
b3fa927e77 | ||
|
|
f71f4c68da | ||
|
|
905d5c0957 | ||
|
|
03a5d05a2c | ||
|
|
069cc6e153 | ||
|
|
daa0cb1e28 | ||
|
|
b147845401 | ||
|
|
b67af56bd1 | ||
|
|
48c5c5bb33 | ||
| 1e2a1dae62 | |||
|
|
2a8042ba50 | ||
|
|
bc32648918 | ||
|
|
9027917ea2 | ||
|
|
5244698384 | ||
|
|
17ca857cc3 | ||
|
|
e6a85a9de4 | ||
|
|
a4ea44675a | ||
|
|
e5d0c690b7 | ||
|
|
0255d7dda1 | ||
|
|
dd7ab2b8e7 | ||
|
|
73c06169f3 | ||
|
|
5e8e7947f0 | ||
|
|
649f5a8570 | ||
| e6ba2259cb | |||
|
|
27d51ffdb1 | ||
|
|
53d4d5768b | ||
|
|
3ff89d43ed | ||
|
|
5c55441e6c | ||
|
|
e432153083 | ||
|
|
b16b619fc9 |
21
CLAUDE.md
21
CLAUDE.md
@@ -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
|
||||
@@ -264,3 +272,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -69,3 +69,8 @@ when@test:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\SkeletonStructureService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.29'
|
||||
app.version: '1.9.36'
|
||||
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -739,12 +744,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
loadProducts().catch(() => {})
|
||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||
if (!props.piece.documents?.length) refreshDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal file
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
63
frontend/app/composables/useConstructeurCategories.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal file
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
const collapseToggleToken = ref(0)
|
||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||
|
||||
const componentTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
componentTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceTypeLabelMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
pieceTypeOptions.value.forEach((type) => {
|
||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// Machine field methods
|
||||
const initMachineFields = () => {
|
||||
if (machine.value) {
|
||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI methods
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
debug.value = !debug.value
|
||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||
refreshMachineDocuments()
|
||||
}
|
||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
await productsPromise
|
||||
const linksApplied = applyMachineLinks(machineResult.data)
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
if (!linksApplied) {
|
||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
}
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
isEditMode,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
// Computed
|
||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
||||
componentTypeOptions, pieceTypeOptions,
|
||||
productInventory, productById, flattenedComponents, machinePieces,
|
||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useToast() {
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration: type === 'error' ? 0 : duration,
|
||||
duration,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
@@ -42,8 +42,7 @@ export function useToast() {
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// Only auto-dismiss non-error toasts
|
||||
if (type !== 'error' && duration > 0) {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
@@ -56,8 +55,8 @@ export function useToast() {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message: string): number => {
|
||||
return showToast(message, 'error', 0)
|
||||
const showError = (message: string, duration = 8000): number => {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
const showWarning = (message: string, duration = 6000): number => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -715,10 +715,12 @@
|
||||
</p>
|
||||
<p class="text-base-content/70 leading-relaxed mb-4">
|
||||
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
|
||||
l'element est monte sur une machine. Par exemple, la "position sur la machine"
|
||||
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ
|
||||
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine,
|
||||
on veut savoir ou elle est installee.
|
||||
l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
|
||||
machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
|
||||
bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
|
||||
donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on
|
||||
regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des
|
||||
deux c'est.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -731,16 +733,16 @@
|
||||
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Debit max</span>
|
||||
<span>120 L/min</span>
|
||||
<span class="text-base-content/70">Diametre interieur</span>
|
||||
<span>50 mm</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">ATEX</span>
|
||||
<span>Oui</span>
|
||||
<span class="text-base-content/70">Type</span>
|
||||
<span>Roulement a billes</span>
|
||||
</div>
|
||||
<div class="border-t border-dashed border-base-300 pt-2 mt-2">
|
||||
<div class="flex justify-between opacity-30">
|
||||
<span class="line-through">Position sur la machine</span>
|
||||
<span class="line-through">Emplacement</span>
|
||||
<span class="text-xs italic">pas affiche ici</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,17 +755,17 @@
|
||||
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Debit max</span>
|
||||
<span>120 L/min</span>
|
||||
<span class="text-base-content/70">Diametre interieur</span>
|
||||
<span>50 mm</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">ATEX</span>
|
||||
<span>Oui</span>
|
||||
<span class="text-base-content/70">Type</span>
|
||||
<span>Roulement a billes</span>
|
||||
</div>
|
||||
<div class="border-t border-primary/20 pt-2 mt-2">
|
||||
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
|
||||
<span class="text-base-content font-medium">Position sur la machine</span>
|
||||
<span class="font-bold">Secteur B - Ligne 3</span>
|
||||
<span class="text-base-content font-medium">Emplacement</span>
|
||||
<span class="font-bold">Haut (palier de tete)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(' • ');
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
|
||||
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'document',
|
||||
d.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', d.id,
|
||||
'name', d.name,
|
||||
'filename', d.filename,
|
||||
'composantId', d.composantid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM documents d
|
||||
WHERE d.composantid IS NOT NULL
|
||||
AND d.composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||
SELECT
|
||||
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||
'machine_component_link',
|
||||
l.id,
|
||||
'delete',
|
||||
json_build_object(
|
||||
'id', l.id,
|
||||
'machineId', l.machineid,
|
||||
'composantId', l.composantid,
|
||||
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||
),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM machine_component_links l
|
||||
WHERE l.composantid IS NOT NULL
|
||||
AND l.composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
// 2. Nettoyage des orphelins.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM documents
|
||||
WHERE composantid IS NOT NULL
|
||||
AND composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM machine_component_links
|
||||
WHERE composantid IS NOT NULL
|
||||
AND composantid NOT IN (SELECT id FROM composants)
|
||||
SQL);
|
||||
|
||||
// 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine).
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents'
|
||||
) THEN
|
||||
ALTER TABLE documents ADD CONSTRAINT fk_documents_composant
|
||||
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links'
|
||||
) THEN
|
||||
ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant
|
||||
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant');
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
295
src/Command/ImportFournisseursCommand.php
Normal file
295
src/Command/ImportFournisseursCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
37
src/Controller/CustomFieldNamesController.php
Normal file
37
src/Controller/CustomFieldNamesController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -162,9 +163,6 @@ class MachineStructureController extends AbstractController
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
// Copy context field values
|
||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
@@ -230,6 +228,17 @@ class MachineStructureController extends AbstractController
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
|
||||
foreach ($link->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachineComponentLink($newLink);
|
||||
$newValue->setComposant($newLink->getComposant());
|
||||
$this->entityManager->persist($newValue);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
@@ -269,6 +278,17 @@ class MachineStructureController extends AbstractController
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
|
||||
foreach ($link->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachinePieceLink($newLink);
|
||||
$newValue->setPiece($newLink->getPiece());
|
||||
$this->entityManager->persist($newValue);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
@@ -317,47 +337,6 @@ class MachineStructureController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneContextFieldValues(
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
foreach ($componentLinkMap as $oldLinkId => $newLink) {
|
||||
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
|
||||
if (!$oldLink) {
|
||||
continue;
|
||||
}
|
||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachineComponentLink($newLink);
|
||||
$newValue->setComposant($newLink->getComposant());
|
||||
$this->entityManager->persist($newValue);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
|
||||
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
|
||||
if (!$oldLink) {
|
||||
continue;
|
||||
}
|
||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachinePieceLink($newLink);
|
||||
$newValue->setPiece($newLink->getPiece());
|
||||
$this->entityManager->persist($newValue);
|
||||
$newLink->getContextFieldValues()->add($newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePayloadList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
@@ -894,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(),
|
||||
];
|
||||
@@ -903,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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
src/Entity/ConstructeurCategorie.php
Normal file
92
src/Entity/ConstructeurCategorie.php
Normal 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;
|
||||
}
|
||||
}
|
||||
109
src/Entity/ConstructeurTelephone.php
Normal file
109
src/Entity/ConstructeurTelephone.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use App\Entity\Machine;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
use App\Service\ActorProfileResolver;
|
||||
use BackedEnum;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use Error;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_array;
|
||||
use function is_object;
|
||||
@@ -35,8 +31,7 @@ use function method_exists;
|
||||
abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
protected readonly ActorProfileResolver $actorProfileResolver,
|
||||
) {}
|
||||
|
||||
public function getSubscribedEvents(): array
|
||||
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||
$entityType = $this->entityType();
|
||||
|
||||
if ($this->hasCollectionTracking()) {
|
||||
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
return $entity->getVersion();
|
||||
}
|
||||
|
||||
protected function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
||||
{
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
}
|
||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
}
|
||||
|
||||
// Note: scheduled collection updates/deletions are intentionally not
|
||||
// tracked here — constructeurs are now persisted as ConstructeurLink
|
||||
// entities (OneToMany), so Doctrine no longer fires collection events
|
||||
// for them. Custom field values are handled below.
|
||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||
|
||||
foreach ($pendingUpdates as $entityId => $diff) {
|
||||
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
|
||||
* so Doctrine no longer fires collection update events for them.
|
||||
*/
|
||||
private function collectCollectionUpdate(
|
||||
object $collection,
|
||||
array &$pendingUpdates,
|
||||
array &$pendingSnapshots,
|
||||
array &$pendingEntities,
|
||||
): void {}
|
||||
|
||||
private function collectCustomFieldValueChanges(
|
||||
UnitOfWork $uow,
|
||||
array &$pendingUpdates,
|
||||
|
||||
@@ -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(),
|
||||
))) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
}
|
||||
|
||||
$uow = $em->getUnitOfWork();
|
||||
$actorProfileId = $this->resolveActorProfileId();
|
||||
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||
|
||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||
}
|
||||
|
||||
59
src/Filter/ConstructeurSearchFilter.php
Normal file
59
src/Filter/ConstructeurSearchFilter.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
20
src/Repository/ConstructeurCategorieRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
20
src/Repository/ConstructeurTelephoneRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
41
src/Service/ActorProfileResolver.php
Normal file
41
src/Service/ActorProfileResolver.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
final class ActorProfileResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function resolve(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Throwable;
|
||||
|
||||
final class EntityVersionService
|
||||
@@ -56,7 +55,7 @@ final class EntityVersionService
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly ActorProfileResolver $actorProfileResolver,
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly PieceRepository $pieces,
|
||||
@@ -187,7 +186,7 @@ final class EntityVersionService
|
||||
'restore',
|
||||
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
||||
$this->buildCurrentSnapshot($entityType, $entity),
|
||||
$this->resolveActorProfileId(),
|
||||
$this->actorProfileResolver->resolve(),
|
||||
$newVersion,
|
||||
);
|
||||
$this->em->persist($restoreAuditLog);
|
||||
@@ -917,25 +916,11 @@ final class EntityVersionService
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$snapshot['productSlots'][] = [
|
||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||
}
|
||||
|
||||
if ('piece' === $entityType) {
|
||||
$snapshot['productSlots'] = [];
|
||||
foreach ($entity->getProductSlots() as $slot) {
|
||||
$snapshot['productSlots'][] = [
|
||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||
}
|
||||
|
||||
// Custom field values
|
||||
@@ -953,21 +938,23 @@ final class EntityVersionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current actor profile ID from the session.
|
||||
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
||||
* @param iterable<ComposantProductSlot|PieceProductSlot> $slots
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function resolveActorProfileId(): ?string
|
||||
private function serializeProductSlots(iterable $slots): array
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// No session available (CLI context, etc.)
|
||||
$serialized = [];
|
||||
foreach ($slots as $slot) {
|
||||
$serialized[] = [
|
||||
'id' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
return $serialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Throwable;
|
||||
|
||||
final class ModelTypeCategoryConversionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
private readonly ActorProfileResolver $actorProfileResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::COMPONENT->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
$this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
|
||||
|
||||
return $count;
|
||||
}
|
||||
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::PIECE->value,
|
||||
'cat' => $category->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
|
||||
'action' => 'convert',
|
||||
'diff' => json_encode($diff),
|
||||
'snapshot' => json_encode($snapshot),
|
||||
'actor' => $this->resolveActorProfileId(),
|
||||
'actor' => $this->actorProfileResolver->resolve(),
|
||||
'now' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveActorProfileId(): ?string
|
||||
{
|
||||
try {
|
||||
$session = $this->requestStack->getSession();
|
||||
if ($session instanceof SessionInterface) {
|
||||
$profileId = $session->get('profileId');
|
||||
if ($profileId) {
|
||||
return (string) $profileId;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof Profile) {
|
||||
return $user->getId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal file
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
|
||||
$normalFields = array_filter(
|
||||
$componentLink['composant']['customFields'],
|
||||
fn (array $f) => $f['name'] === 'Serial',
|
||||
fn (array $f) => 'Serial' === $f['name'],
|
||||
);
|
||||
$this->assertCount(1, $normalFields);
|
||||
}
|
||||
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'select',
|
||||
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Flow Rate',
|
||||
type: 'number',
|
||||
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Pressure',
|
||||
type: 'number',
|
||||
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Calibration Date',
|
||||
type: 'date',
|
||||
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM Setting',
|
||||
type: 'number',
|
||||
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testCloneMachineCopiesPieceContextFieldValues(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site G');
|
||||
$modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'text',
|
||||
typePiece: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Source Piece Machine', $site);
|
||||
$piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType);
|
||||
$link = $this->createMachinePieceLink($source, $piece);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: 'Fair',
|
||||
piece: $piece,
|
||||
machinePieceLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Piece Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
|
||||
$clonedLink = $data['pieceLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink');
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site H');
|
||||
$modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Original Machine', $site);
|
||||
$composant = $this->createComposant('Motor S', 'MOTS-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($source, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '1500',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Clone Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
|
||||
// Source must still expose its original context field value
|
||||
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||
$sourceLink = $sourceData['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($sourceLink, 'Source machine should still expose its component link');
|
||||
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
tests/Service/SkeletonStructureServiceTest.php
Normal file
76
tests/Service/SkeletonStructureServiceTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user