feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s

- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N)
- Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write
- Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone
- Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories
- MAJ MCP tools / MachineStructureController / audit subscriber / tests
- Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-12 17:29:28 +02:00
parent b147845401
commit daa0cb1e28
28 changed files with 1317 additions and 109 deletions

View File

@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
```
@@ -116,7 +121,9 @@ Le frontend est un submodule git. Lors d'un commit frontend :
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
#### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
@@ -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

View File

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

View File

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

View File

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

View File

@@ -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<Constructeur>): Promise<ConstructeurResult> => {
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
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<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)

View File

@@ -6,7 +6,7 @@
Fournisseurs
</h1>
<p class="text-sm text-gray-500">
Gérez les fournisseurs et leurs coordonnées.
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
</p>
</div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
@@ -28,20 +28,56 @@
@sort="handleSort"
>
<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 +132,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 +141,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
@@ -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<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
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<Record<string, any> | null>(null)
const form = ref<ConstructeurFormState>({ 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()
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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 +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<int, ConstructeurTelephone>
*/
public function getTelephones(): Collection
{
return $this->phone;
return $this->telephones;
}
public function setPhone(?string $phone): static
public function addTelephone(ConstructeurTelephone $telephone): static
{
$this->phone = $phone;
if (!$this->telephones->contains($telephone)) {
$this->telephones->add($telephone);
$telephone->setConstructeur($this);
}
return $this;
}
public function removeTelephone(ConstructeurTelephone $telephone): static
{
if ($this->telephones->removeElement($telephone)) {
if ($telephone->getConstructeur() === $this) {
$telephone->setConstructeur(null);
}
}
return $this;
}
/**
* @return Collection<int, ConstructeurCategorie>
*/
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(ConstructeurCategorie $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(ConstructeurCategorie $category): static
{
$this->categories->removeElement($category);
return $this;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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