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.