From daa0cb1e2820a529d80beb56a16af64218312ac9 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 12 May 2026 17:29:28 +0200 Subject: [PATCH] feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json - Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N) - Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write - Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone - Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories - MAJ MCP tools / MachineStructureController / audit subscriber / tests - Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +- .../app/components/ConstructeurSelect.vue | 9 +- .../form/ConstructeurCategorieSelect.vue | 153 +++++++++ .../composables/useConstructeurCategories.ts | 63 ++++ frontend/app/composables/useConstructeurs.ts | 23 +- frontend/app/pages/constructeurs.vue | 201 +++++++++--- frontend/app/shared/constructeurUtils.ts | 41 ++- .../composables/useComponentCreate.test.ts | 12 +- .../composables/useComponentEdit.test.ts | 12 +- .../tests/composables/useDocuments.test.ts | 12 +- .../composables/useMachineDetailData.test.ts | 12 +- .../tests/composables/usePieceEdit.test.ts | 12 +- ...000_AddConstructeurCategoriesAndPhones.php | 90 ++++++ src/Command/ImportFournisseursCommand.php | 295 ++++++++++++++++++ src/Controller/MachineStructureController.php | 10 +- src/Entity/Constructeur.php | 81 ++++- src/Entity/ConstructeurCategorie.php | 92 ++++++ src/Entity/ConstructeurTelephone.php | 109 +++++++ .../ConstructeurAuditSubscriber.php | 21 +- .../Constructeur/CreateConstructeurTool.php | 8 +- .../Tool/Constructeur/GetConstructeurTool.php | 22 +- .../Constructeur/ListConstructeursTool.php | 2 +- .../Constructeur/UpdateConstructeurTool.php | 19 +- src/Mcp/Tool/Machine/MachineStructureTool.php | 10 +- .../ConstructeurCategorieRepository.php | 20 ++ .../ConstructeurTelephoneRepository.php | 20 ++ tests/AbstractApiTestCase.php | 35 ++- tests/Api/Entity/ConstructeurTest.php | 31 +- 28 files changed, 1317 insertions(+), 109 deletions(-) create mode 100644 frontend/app/components/form/ConstructeurCategorieSelect.vue create mode 100644 frontend/app/composables/useConstructeurCategories.ts create mode 100644 migrations/Version20260512150000_AddConstructeurCategoriesAndPhones.php create mode 100644 src/Command/ImportFournisseursCommand.php create mode 100644 src/Entity/ConstructeurCategorie.php create mode 100644 src/Entity/ConstructeurTelephone.php create mode 100644 src/Repository/ConstructeurCategorieRepository.php create mode 100644 src/Repository/ConstructeurTelephoneRepository.php diff --git a/CLAUDE.md b/CLAUDE.md index dea8c7f..d36c333 100644 --- a/CLAUDE.md +++ b/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 : @@ -257,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 diff --git a/frontend/app/components/ConstructeurSelect.vue b/frontend/app/components/ConstructeurSelect.vue index 589f1a8..0546d4a 100644 --- a/frontend/app/components/ConstructeurSelect.vue +++ b/frontend/app/components/ConstructeurSelect.vue @@ -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 diff --git a/frontend/app/components/form/ConstructeurCategorieSelect.vue b/frontend/app/components/form/ConstructeurCategorieSelect.vue new file mode 100644 index 0000000..d6c2c9e --- /dev/null +++ b/frontend/app/components/form/ConstructeurCategorieSelect.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/app/composables/useConstructeurCategories.ts b/frontend/app/composables/useConstructeurCategories.ts new file mode 100644 index 0000000..a9c0d69 --- /dev/null +++ b/frontend/app/composables/useConstructeurCategories.ts @@ -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([]) +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 => { + 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(result.data)) + loaded.value = true + } + return categories.value + } + finally { + loading.value = false + } + } + + const createCategory = async (name: string): Promise => { + 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 } +} diff --git a/frontend/app/composables/useConstructeurs.ts b/frontend/app/composables/useConstructeurs.ts index 272d939..ef7e7ea 100644 --- a/frontend/app/composables/useConstructeurs.ts +++ b/frontend/app/composables/useConstructeurs.ts @@ -3,11 +3,28 @@ import { useApi } from './useApi' import { useToast } from './useToast' import { extractCollection } 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 { @@ -87,7 +104,7 @@ export function useConstructeurs() { return loadConstructeurs(search) } - const createConstructeur = async (data: Partial): Promise => { + const createConstructeur = async (data: Record): Promise => { loading.value = true try { const result = await post('/constructeurs', data) @@ -161,7 +178,7 @@ export function useConstructeurs() { .filter((item): item is Constructeur => item !== null) } - const updateConstructeur = async (id: string, data: Partial): Promise => { + const updateConstructeur = async (id: string, data: Record): Promise => { loading.value = true try { const result = await patch(`/constructeurs/${id}`, data) diff --git a/frontend/app/pages/constructeurs.vue b/frontend/app/pages/constructeurs.vue index 3dea1ba..1e3268b 100644 --- a/frontend/app/pages/constructeurs.vue +++ b/frontend/app/pages/constructeurs.vue @@ -6,7 +6,7 @@ Fournisseurs

- Gérez les fournisseurs et leurs coordonnées. + Gérez les fournisseurs, leurs coordonnées et leurs catégories.