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 @@ + + + + + Aucune catégorie + + + {{ cat.name }} + + + + + + + + + + + {{ cat.name }} + + + + Créer « {{ searchTerm.trim() }} » + + + + + + + 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. @@ -28,20 +28,56 @@ @sort="handleSort" > - - Recherche - - + + + Recherche + + + + Catégorie + + + Toutes les catégories + + + {{ cat.name }} + + + + - - {{ formatPhoneDisplay(row.phone) }} + + + + {{ formatPhoneDisplay(tel.numero) }} + ({{ tel.label }}) + + + — + + + + + + {{ cat.name }} + + + — @@ -96,7 +132,7 @@ - + {{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur @@ -105,10 +141,53 @@ Nom - - - + + + + + + Téléphones + + + Ajouter + + + + Aucun téléphone. + + + + + + + + + + + + + Catégories + + + Annuler @@ -129,22 +208,36 @@ import { ref, computed, onMounted } 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 { categories: allCategories, loadCategories } = useConstructeurCategories() const { showError } = useToast() 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 +246,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>({}) const currentSort = computed(() => ({ field: sortKey.value, @@ -169,23 +263,29 @@ const handleSort = (sort) => { const modalOpen = ref(false) const saving = ref(false) -const editingConstructeur = ref(null) -const form = ref({ name: '', email: '', phone: '' }) +const editingConstructeur = ref | null>(null) +const form = ref({ name: '', email: '', telephones: [], categories: [] }) + +const rowPhones = constructeurPhones const filteredConstructeurs = computed(() => { const key = sortKey.value const dir = sortDir.value === 'desc' ? -1 : 1 - const sorted = [...constructeurs.value].sort((a, b) => { + let 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 (selectedCategoryId.value) { + sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value)) + } 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)), - ) + return sorted.filter((item) => { + const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)] + return haystack.some(value => value && String(value).toLowerCase().includes(term)) + }) }) const debouncedSearch = debounce(async () => { @@ -194,13 +294,7 @@ const debouncedSearch = debounce(async () => { 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 +305,7 @@ function debounce(fn, delay) { } const resetForm = () => { - form.value = { name: '', email: '', phone: '' } + form.value = { name: '', email: '', telephones: [], categories: [] } editingConstructeur.value = null } @@ -225,7 +319,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 +334,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 +358,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) @@ -283,6 +409,7 @@ const loadStats = async () => { onMounted(() => { loadConstructeurs() + loadCategories() loadStats() }) diff --git a/frontend/app/shared/constructeurUtils.ts b/frontend/app/shared/constructeurUtils.ts index 843909d..e6f519c 100644 --- a/frontend/app/shared/constructeurUtils.ts +++ b/frontend/app/shared/constructeurUtils.ts @@ -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(' • '); }; diff --git a/frontend/tests/composables/useComponentCreate.test.ts b/frontend/tests/composables/useComponentCreate.test.ts index 082051b..4816568 100644 --- a/frontend/tests/composables/useComponentCreate.test.ts +++ b/frontend/tests/composables/useComponentCreate.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/tests/composables/useComponentEdit.test.ts b/frontend/tests/composables/useComponentEdit.test.ts index d31e97c..643b583 100644 --- a/frontend/tests/composables/useComponentEdit.test.ts +++ b/frontend/tests/composables/useComponentEdit.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/tests/composables/useDocuments.test.ts b/frontend/tests/composables/useDocuments.test.ts index f239676..06bcb40 100644 --- a/frontend/tests/composables/useDocuments.test.ts +++ b/frontend/tests/composables/useDocuments.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/tests/composables/useMachineDetailData.test.ts b/frontend/tests/composables/useMachineDetailData.test.ts index c2f3ad2..397d10b 100644 --- a/frontend/tests/composables/useMachineDetailData.test.ts +++ b/frontend/tests/composables/useMachineDetailData.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/frontend/tests/composables/usePieceEdit.test.ts b/frontend/tests/composables/usePieceEdit.test.ts index de4592e..66f4ae8 100644 --- a/frontend/tests/composables/usePieceEdit.test.ts +++ b/frontend/tests/composables/usePieceEdit.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/migrations/Version20260512150000_AddConstructeurCategoriesAndPhones.php b/migrations/Version20260512150000_AddConstructeurCategoriesAndPhones.php new file mode 100644 index 0000000..5328415 --- /dev/null +++ b/migrations/Version20260512150000_AddConstructeurCategoriesAndPhones.php @@ -0,0 +1,90 @@ +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'); + } +} diff --git a/src/Command/ImportFournisseursCommand.php b/src/Command/ImportFournisseursCommand.php new file mode 100644 index 0000000..7a8befd --- /dev/null +++ b/src/Command/ImportFournisseursCommand.php @@ -0,0 +1,295 @@ +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> $rows */ + $rows = $decoded['data']; + if (null !== $limit) { + $rows = array_slice($rows, 0, $limit); + } + + $io->title('Import fournisseurs'); + $io->writeln(sprintf('Fichier : %s', $path)); + $io->writeln(sprintf('Entrées : %d', count($rows))); + $io->writeln($write ? 'Mode écriture (--force)' : 'Mode dry-run — aucune écriture. Ajouter --force pour appliquer.'); + $io->newLine(); + + // --- Chargement des référentiels existants --------------------------------- + /** @var array $constructeursByName */ + $constructeursByName = []; + foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) { + $constructeursByName[$this->normalizeKey((string) $c->getName())] = $c; + } + + /** @var array $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 (clé = numéro normalisé) + $seenCatLinks = new SplObjectStorage(); // Constructeur => array (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 $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 + */ + 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 + */ + 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)); + } +} diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 3a999b1..4859235 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Composant; +use App\Entity\Constructeur; use App\Entity\CustomField; use App\Entity\CustomFieldValue; use App\Entity\Machine; @@ -872,7 +873,7 @@ class MachineStructureController extends AbstractController 'id' => $link->getConstructeur()->getId(), 'name' => $link->getConstructeur()->getName(), 'email' => $link->getConstructeur()->getEmail(), - 'phone' => $link->getConstructeur()->getPhone(), + 'phone' => $this->constructeurPhone($link->getConstructeur()), ], 'supplierReference' => $link->getSupplierReference(), ]; @@ -881,6 +882,13 @@ class MachineStructureController extends AbstractController return $items; } + private function constructeurPhone(Constructeur $constructeur): ?string + { + $first = $constructeur->getTelephones()->first(); + + return false !== $first ? $first->getNumero() : null; + } + private function normalizeCustomFieldDefinitions(Collection $customFields): array { $items = []; diff --git a/src/Entity/Constructeur.php b/src/Entity/Constructeur.php index 6b6c29f..6cee83f 100644 --- a/src/Entity/Constructeur.php +++ b/src/Entity/Constructeur.php @@ -19,6 +19,7 @@ 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à.')] @@ -36,7 +37,9 @@ use Symfony\Component\Validator\Constraints as Assert; new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), ], paginationClientItemsPerPage: true, - paginationMaximumItemsPerPage: 200 + paginationMaximumItemsPerPage: 200, + normalizationContext: ['groups' => ['constructeur:read']], + denormalizationContext: ['groups' => ['constructeur:write']] )] class Constructeur { @@ -44,24 +47,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 + */ + #[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['constructeur:read', 'constructeur:write'])] + private Collection $telephones; + + /** + * @var Collection + */ + #[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 */ @@ -94,6 +116,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 +144,55 @@ class Constructeur return $this; } - public function getPhone(): ?string + /** + * @return Collection + */ + 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 + */ + 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; } diff --git a/src/Entity/ConstructeurCategorie.php b/src/Entity/ConstructeurCategorie.php new file mode 100644 index 0000000..0aadf8b --- /dev/null +++ b/src/Entity/ConstructeurCategorie.php @@ -0,0 +1,92 @@ + '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 + */ + #[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; + } +} diff --git a/src/Entity/ConstructeurTelephone.php b/src/Entity/ConstructeurTelephone.php new file mode 100644 index 0000000..86dbcce --- /dev/null +++ b/src/Entity/ConstructeurTelephone.php @@ -0,0 +1,109 @@ + '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; + } +} diff --git a/src/EventSubscriber/ConstructeurAuditSubscriber.php b/src/EventSubscriber/ConstructeurAuditSubscriber.php index b506085..81c1cb6 100644 --- a/src/EventSubscriber/ConstructeurAuditSubscriber.php +++ b/src/EventSubscriber/ConstructeurAuditSubscriber.php @@ -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(), + ))) : [], ]; } } diff --git a/src/Mcp/Tool/Constructeur/CreateConstructeurTool.php b/src/Mcp/Tool/Constructeur/CreateConstructeurTool.php index 20de306..8f2959c 100644 --- a/src/Mcp/Tool/Constructeur/CreateConstructeurTool.php +++ b/src/Mcp/Tool/Constructeur/CreateConstructeurTool.php @@ -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(); diff --git a/src/Mcp/Tool/Constructeur/GetConstructeurTool.php b/src/Mcp/Tool/Constructeur/GetConstructeurTool.php index c59d43f..cf386b0 100644 --- a/src/Mcp/Tool/Constructeur/GetConstructeurTool.php +++ b/src/Mcp/Tool/Constructeur/GetConstructeurTool.php @@ -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'), ]); } } diff --git a/src/Mcp/Tool/Constructeur/ListConstructeursTool.php b/src/Mcp/Tool/Constructeur/ListConstructeursTool.php index 26df639..5573630 100644 --- a/src/Mcp/Tool/Constructeur/ListConstructeursTool.php +++ b/src/Mcp/Tool/Constructeur/ListConstructeursTool.php @@ -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') ; diff --git a/src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php b/src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php index 51efb18..1012b37 100644 --- a/src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php +++ b/src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php @@ -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(); diff --git a/src/Mcp/Tool/Machine/MachineStructureTool.php b/src/Mcp/Tool/Machine/MachineStructureTool.php index b433eff..992da6e 100644 --- a/src/Mcp/Tool/Machine/MachineStructureTool.php +++ b/src/Mcp/Tool/Machine/MachineStructureTool.php @@ -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 = []; diff --git a/src/Repository/ConstructeurCategorieRepository.php b/src/Repository/ConstructeurCategorieRepository.php new file mode 100644 index 0000000..93ee97b --- /dev/null +++ b/src/Repository/ConstructeurCategorieRepository.php @@ -0,0 +1,20 @@ + + */ +class ConstructeurCategorieRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ConstructeurCategorie::class); + } +} diff --git a/src/Repository/ConstructeurTelephoneRepository.php b/src/Repository/ConstructeurTelephoneRepository.php new file mode 100644 index 0000000..cbe0df3 --- /dev/null +++ b/src/Repository/ConstructeurTelephoneRepository.php @@ -0,0 +1,20 @@ + + */ +class ConstructeurTelephoneRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ConstructeurTelephone::class); + } +} diff --git a/tests/AbstractApiTestCase.php b/tests/AbstractApiTestCase.php index a0d8012..89e5e44 100644 --- a/tests/AbstractApiTestCase.php +++ b/tests/AbstractApiTestCase.php @@ -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(); diff --git a/tests/Api/Entity/ConstructeurTest.php b/tests/Api/Entity/ConstructeurTest.php index dc2063b..054a471 100644 --- a/tests/Api/Entity/ConstructeurTest.php +++ b/tests/Api/Entity/ConstructeurTest.php @@ -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
- Gérez les fournisseurs et leurs coordonnées. + Gérez les fournisseurs, leurs coordonnées et leurs catégories.
+ Aucun téléphone. +