Compare commits

..

11 Commits

Author SHA1 Message Date
Matthieu
6e105fd070 chore : bump version to v1.9.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu
a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
Matthieu
d3f269452c chore : bump version to v1.9.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-13 10:46:51 +02:00
gitea-actions
b3fa927e77 chore : bump version to v1.9.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu
f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
gitea-actions
905d5c0957 chore : bump version to v1.9.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-05-13 08:23:57 +00:00
Matthieu
03a5d05a2c feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00
gitea-actions
069cc6e153 chore : bump version to v1.9.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-13 08:02:55 +00:00
Matthieu
daa0cb1e28 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>
2026-05-13 10:02:44 +02:00
gitea-actions
b147845401 chore : bump version to v1.9.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-11 14:52:40 +00:00
Matthieu
b67af56bd1 fix(search-select) : affiche modelValue au mount en mode creatable
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
En mode creatable, modelValue n'est pas dans options donc selectedOption est null.
Le onMounted ecrasait searchTerm a vide apres que le watch immediate l'avait initialise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:52:28 +02:00
33 changed files with 1558 additions and 157 deletions

View File

@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony 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 # Release
./scripts/release.sh patch # Bump patch version (ou minor/major) ./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 ## Architecture Backend
### Entités Principales ### 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) #### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles : 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 ### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories) - Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback - 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()` - Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales ## URLs Locales

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.31' app.version: '1.9.37'

View File

@@ -69,9 +69,25 @@
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span> <span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags --> <!-- Row 2: Metadata tags -->
<div <div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)" v-if="componentConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5" class="flex flex-wrap items-center gap-1.5"
> >
<span <span
@@ -85,17 +101,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>

View File

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

View File

@@ -71,9 +71,25 @@
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span> <span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
</div> </div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags --> <!-- Row 2: Metadata tags -->
<div <div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)" v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5" class="flex flex-wrap items-center gap-1.5"
> >
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded"> <span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
@@ -90,17 +106,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>

View File

@@ -388,7 +388,11 @@ const handleGlobalClick = (event) => {
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleGlobalClick) window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : '' if (props.creatable) {
searchTerm.value = String(props.modelValue ?? '')
} else {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

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

@@ -1,13 +1,30 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone {
'@id'?: string
id?: string
numero: string
label?: string | null
}
export interface ConstructeurCategorieRef {
'@id'?: string
id: string
name: string
}
export interface Constructeur { export interface Constructeur {
'@id'?: string
id: string id: string
name: string name: string
email?: string | null email?: string | null
phone?: string | null telephones?: ConstructeurTelephone[]
categories?: ConstructeurCategorieRef[]
createdAt?: string
updatedAt?: string
} }
interface ConstructeurResult { interface ConstructeurResult {
@@ -16,6 +33,24 @@ interface ConstructeurResult {
error?: string error?: string
} }
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([]) const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -66,8 +101,10 @@ export function useConstructeurs() {
} }
loading.value = true loading.value = true
try { try {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const params = new URLSearchParams()
const result = await get(`/constructeurs${query}`) params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items) constructeurs.value = uniqueConstructeurs(items)
@@ -87,7 +124,38 @@ export function useConstructeurs() {
return loadConstructeurs(search) return loadConstructeurs(search)
} }
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => { const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await post('/constructeurs', data) const result = await post('/constructeurs', data)
@@ -161,7 +229,7 @@ export function useConstructeurs() {
.filter((item): item is Constructeur => item !== null) .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 loading.value = true
try { try {
const result = await patch(`/constructeurs/${id}`, data) const result = await patch(`/constructeurs/${id}`, data)
@@ -210,6 +278,7 @@ export function useConstructeurs() {
loading, loading,
loadConstructeurs, loadConstructeurs,
searchConstructeurs, searchConstructeurs,
fetchConstructeursPage,
createConstructeur, createConstructeur,
updateConstructeur, updateConstructeur,
deleteConstructeur, deleteConstructeur,

View File

@@ -6,7 +6,7 @@
Fournisseurs Fournisseurs
</h1> </h1>
<p class="text-sm text-gray-500"> <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> </p>
</div> </div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
@@ -19,29 +19,69 @@
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="filteredConstructeurs" :rows="pageItems"
:loading="loading" :loading="loading"
:sort="currentSort" :sort="currentSort"
:show-counter="false" :pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé." empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé." no-results-message="Aucun fournisseur trouvé."
@sort="handleSort" @sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
> >
<template #toolbar> <template #toolbar>
<label class="w-full sm:w-72"> <div class="flex flex-col sm:flex-row gap-3 w-full">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span> <label class="w-full sm:w-72">
<input <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
v-model="searchTerm" <input
type="search" v-model="searchTerm"
class="input input-bordered input-sm w-full mt-1" type="search"
placeholder="Nom, email ou téléphone" class="input input-bordered input-sm w-full mt-1"
@input="debouncedSearch" placeholder="Nom, email ou téléphone"
/> @input="debouncedSearch"
</label> >
</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>
<template #cell-phone="{ row }"> <template #cell-telephones="{ row }">
{{ formatPhoneDisplay(row.phone) }} <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>
<template #cell-createdAt="{ row }"> <template #cell-createdAt="{ row }">
@@ -96,7 +136,7 @@
</div> </div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }"> <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"> <h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur {{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3> </h3>
@@ -105,10 +145,53 @@
<label class="label"><span class="label-text">Nom</span></label> <label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required> <input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" /> <FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :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>
<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"> <div class="modal-action">
<button type="button" class="btn" @click="closeModal"> <button type="button" class="btn" @click="closeModal">
Annuler Annuler
@@ -125,26 +208,47 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue' import { usePersistedValue } from '~/composables/usePersistedValue'
import { constructeurPhones } from '~/shared/constructeurUtils'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' 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 api = useApi()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast() const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', 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: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' }, { key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' }, { key: 'pieceCount', label: 'Pièces', align: 'center' },
@@ -153,9 +257,10 @@ const columns = [
] ]
const searchTerm = ref('') const searchTerm = ref('')
const selectedCategoryId = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name') const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc') const sortDir = ref('asc')
const stats = ref({}) const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
const currentSort = computed(() => ({ const currentSort = computed(() => ({
field: sortKey.value, field: sortKey.value,
@@ -167,40 +272,80 @@ const handleSort = (sort) => {
sortDir.value = sort.direction sortDir.value = sort.direction
} }
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref(null) const editingConstructeur = ref<Record<string, any> | null>(null)
const form = ref({ name: '', email: '', phone: '' }) const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
const filteredConstructeurs = computed(() => { const rowPhones = constructeurPhones
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1 const debouncedSearch = debounce(() => {
const sorted = [...constructeurs.value].sort((a, b) => { currentPage.value = 1
if (key === 'createdAt') { loadPage()
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime()) }, 300)
}
return dir * (a[key] || '').localeCompare(b[key] || '') watch(selectedCategoryId, () => {
}) currentPage.value = 1
if (!searchTerm.value) { return sorted } loadPage()
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
}) })
const debouncedSearch = debounce(async () => { watch([sortKey, sortDir], () => {
await searchConstructeurs(searchTerm.value) currentPage.value = 1
}, 300) loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => { const formatPhoneDisplay = value => formatPhone(value) || value || '—'
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
function debounce(fn, delay) { function debounce(fn, delay) {
let timeout let timeout
@@ -211,7 +356,7 @@ function debounce(fn, delay) {
} }
const resetForm = () => { const resetForm = () => {
form.value = { name: '', email: '', phone: '' } form.value = { name: '', email: '', telephones: [], categories: [] }
editingConstructeur.value = null editingConstructeur.value = null
} }
@@ -225,7 +370,12 @@ const openEditModal = (constructeur) => {
form.value = { form.value = {
name: constructeur.name, name: constructeur.name,
email: constructeur.email || '', 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 modalOpen.value = true
} }
@@ -235,8 +385,20 @@ const closeModal = () => {
resetForm() resetForm()
} }
const addTelephoneRow = () => {
form.value.telephones.push({ numero: '', label: '' })
}
const removeTelephoneRow = (idx) => {
form.value.telephones.splice(idx, 1)
}
const saveConstructeur = async () => { const saveConstructeur = async () => {
const trimmedName = form.value.name.trim() const trimmedName = form.value.name.trim()
if (!trimmedName) {
showError('Le nom est obligatoire.')
return
}
const duplicate = constructeurs.value.find( const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase() c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id, && c.id !== editingConstructeur.value?.id,
@@ -247,9 +409,24 @@ const saveConstructeur = async () => {
} }
saving.value = true saving.value = true
const payload = { ...form.value, name: trimmedName } const payload = {
if (!payload.email) { delete payload.email } name: trimmedName,
if (!payload.phone) { delete payload.phone } 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 let result
if (editingConstructeur.value) { if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload) result = await updateConstructeur(editingConstructeur.value.id, payload)
@@ -260,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false saving.value = false
if (result.success) { if (result.success) {
closeModal() closeModal()
await searchConstructeurs(searchTerm.value) await loadPage()
} }
} }
@@ -271,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)
return
}
if (result.success) {
await loadPage()
} }
} }
@@ -282,7 +463,8 @@ const loadStats = async () => {
} }
onMounted(() => { onMounted(() => {
loadConstructeurs() loadPage()
loadCategories()
loadStats() loadStats()
}) })
</script> </script>

View File

@@ -1,12 +1,49 @@
import { formatPhone } from '~/utils/formatters/phone'; import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurTelephoneSummary {
numero?: string | null;
label?: string | null;
}
export interface ConstructeurSummary { export interface ConstructeurSummary {
id: string; id: string;
name?: string | null; name?: string | null;
email?: string | null; email?: string | null;
// Legacy single-phone string: still exposed by the machine-structure normalization.
phone?: string | null; 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 { export interface ConstructeurLinkEntry {
linkId?: string; linkId?: string;
constructeurId: string; constructeurId: string;
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
return ''; return '';
} }
const formattedPhone = formatPhone(constructeur.phone); const primary = constructeurPrimaryPhone(constructeur);
const phone = formattedPhone || constructeur.phone || null; const phone = formatPhone(primary) || primary || null;
return [constructeur.email, phone].filter(Boolean).join(' • '); 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 { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // Mocks — API layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -206,12 +212,6 @@ vi.mock('~/shared/constructeurUtils', () => ({
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId), constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
})) }))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

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

View File

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

View File

@@ -9,6 +9,12 @@ import {
wrapCollection, wrapCollection,
} from '../fixtures/mockData' } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // 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 // 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; namespace App\Controller;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -872,7 +873,7 @@ class MachineStructureController extends AbstractController
'id' => $link->getConstructeur()->getId(), 'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(), 'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(), 'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(), 'phone' => $this->constructeurPhone($link->getConstructeur()),
], ],
'supplierReference' => $link->getSupplierReference(), 'supplierReference' => $link->getSupplierReference(),
]; ];
@@ -881,6 +882,13 @@ class MachineStructureController extends AbstractController
return $items; return $items;
} }
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array private function normalizeCustomFieldDefinitions(Collection $customFields): array
{ {
$items = []; $items = [];

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity; 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\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -19,12 +23,16 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')] #[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)] #[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')] #[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.', description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [ operations: [
@@ -36,7 +44,9 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200 paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']]
)] )]
class Constructeur class Constructeur
{ {
@@ -44,24 +54,43 @@ class Constructeur
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')] #[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $name = null; private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $email = null; 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')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $updatedAt; 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> * @var Collection<int, MachineConstructeurLink>
*/ */
@@ -94,6 +123,8 @@ class Constructeur
$this->composantLinks = new ArrayCollection(); $this->composantLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection();
$this->telephones = new ArrayCollection();
$this->categories = new ArrayCollection();
} }
public function getName(): ?string public function getName(): ?string
@@ -120,14 +151,55 @@ class Constructeur
return $this; 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; 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; namespace App\EventSubscriber;
use App\Entity\Constructeur; use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array protected function snapshotEntity(object $entity): array
{ {
$telephones = $this->safeGet($entity, 'getTelephones');
$categories = $this->safeGet($entity, 'getCategories');
return [ return [
'id' => $entity->getId(), 'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'), 'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'), 'email' => $this->safeGet($entity, 'getEmail'),
'phone' => $this->safeGet($entity, 'getPhone'), '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

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
}
}

View File

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

View File

@@ -29,13 +29,23 @@ class GetConstructeurTool
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}"); $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([ return $this->jsonResponse([
'id' => $constructeur->getId(), 'id' => $constructeur->getId(),
'name' => $constructeur->getName(), 'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(), 'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(), 'telephones' => array_values($telephones),
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'), 'categories' => $categories,
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'), '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') $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') ->orderBy('c.name', 'ASC')
; ;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur; namespace App\Mcp\Tool\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper; use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
#[McpTool( #[McpTool(
name: 'update_constructeur', 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 class UpdateConstructeurTool
{ {
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
if (null !== $email) { if (null !== $email) {
$constructeur->setEmail($email); $constructeur->setEmail($email);
} }
if (null !== $phone) { if (null !== $phone && '' !== $phone) {
$constructeur->setPhone($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(); $this->em->flush();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Machine; namespace App\Mcp\Tool\Machine;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -364,7 +365,7 @@ class MachineStructureTool
'id' => $link->getConstructeur()->getId(), 'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(), 'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(), 'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(), 'phone' => $this->constructeurPhone($link->getConstructeur()),
], ],
'supplierReference' => $link->getSupplierReference(), 'supplierReference' => $link->getSupplierReference(),
]; ];
@@ -373,6 +374,13 @@ class MachineStructureTool
return $items; return $items;
} }
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFields(Collection $customFields): array private function normalizeCustomFields(Collection $customFields): array
{ {
$items = []; $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\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot; use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Constructeur; use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -250,7 +252,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
$c = new Constructeur(); $c = new Constructeur();
$c->setName($name); $c->setName($name);
$c->setEmail($email); $c->setEmail($email);
$c->setPhone($phone);
if (null !== $phone) {
$tel = new ConstructeurTelephone();
$tel->setNumero($phone);
$c->addTelephone($tel);
}
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$em->persist($c); $em->persist($c);
@@ -259,6 +266,32 @@ abstract class AbstractApiTestCase extends ApiTestCase
return $c; 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 protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{ {
$link = new MachineConstructeurLink(); $link = new MachineConstructeurLink();

View File

@@ -33,9 +33,9 @@ class ConstructeurTest extends AbstractApiTestCase
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$this->assertJsonContains([ $this->assertJsonContains([
'name' => 'Siemens', 'name' => 'Siemens',
'email' => 'contact@siemens.com', 'email' => 'contact@siemens.com',
'phone' => '+33123456789', 'telephones' => [['numero' => '+33123456789']],
]); ]);
} }
@@ -78,11 +78,32 @@ class ConstructeurTest extends AbstractApiTestCase
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [ $client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'], 'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['phone' => '+33987654321'], 'json' => ['email' => 'updated@siemens.com'],
]); ]);
$this->assertResponseIsSuccessful(); $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 public function testDelete(): void