Compare commits
13 Commits
feat/custo
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e105fd070 | ||
|
|
a0c4597de0 | ||
|
|
d3f269452c | ||
|
|
b3fa927e77 | ||
|
|
f71f4c68da | ||
|
|
905d5c0957 | ||
|
|
03a5d05a2c | ||
|
|
069cc6e153 | ||
|
|
daa0cb1e28 | ||
|
|
b147845401 | ||
|
|
b67af56bd1 | ||
|
|
48c5c5bb33 | ||
| 1e2a1dae62 |
11
CLAUDE.md
11
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.30'
|
app.version: '1.9.37'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,11 @@ const handleGlobalClick = (event) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('click', handleGlobalClick)
|
window.addEventListener('click', handleGlobalClick)
|
||||||
|
if (props.creatable) {
|
||||||
|
searchTerm.value = String(props.modelValue ?? '')
|
||||||
|
} else {
|
||||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal 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>
|
||||||
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
63
frontend/app/composables/useConstructeurCategories.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,15 +19,20 @@
|
|||||||
<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>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
||||||
<label class="w-full sm:w-72">
|
<label class="w-full sm:w-72">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
<input
|
<input
|
||||||
@@ -36,12 +41,47 @@
|
|||||||
class="input input-bordered input-sm w-full mt-1"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
placeholder="Nom, email ou téléphone"
|
placeholder="Nom, email ou téléphone"
|
||||||
@input="debouncedSearch"
|
@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>
|
</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">
|
<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 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 (!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)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const debouncedSearch = debounce(async () => {
|
const debouncedSearch = debounce(() => {
|
||||||
await searchConstructeurs(searchTerm.value)
|
currentPage.value = 1
|
||||||
|
loadPage()
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
|
watch(selectedCategoryId, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([sortKey, sortDir], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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(' • ');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/Command/ImportFournisseursCommand.php
Normal file
295
src/Command/ImportFournisseursCommand.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/Entity/ConstructeurCategorie.php
Normal file
92
src/Entity/ConstructeurCategorie.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/Entity/ConstructeurTelephone.php
Normal file
109
src/Entity/ConstructeurTelephone.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
))) : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/Filter/ConstructeurSearchFilter.php
Normal file
65
src/Filter/ConstructeurSearchFilter.php
Normal 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)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -29,11 +29,21 @@ 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),
|
||||||
|
'categories' => $categories,
|
||||||
'createdAt' => $constructeur->getCreatedAt()->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'),
|
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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')
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
20
src/Repository/ConstructeurCategorieRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
20
src/Repository/ConstructeurTelephoneRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ConstructeurTest extends AbstractApiTestCase
|
|||||||
$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
|
||||||
|
|||||||
Reference in New Issue
Block a user