feat(catalog) : categories multi-types (M:N) + filtres liste
Volet A — relation Category <-> CategoryType passee de ManyToOne a ManyToMany (jonction category_category_type). Au moins un type obligatoire (Assert\Count), unicite du nom desormais GLOBALE parmi les actifs (uq_category_name_active). Migration avec backfill + drop de l'ancienne colonne. Contrat Shared CategoryInterface : getCategoryTypeCode() -> getCategoryTypeCodes(): array ; RG-2.10 fournisseurs (Supplier / SupplierAddress / fixtures) revalident « contient FOURNISSEUR ». Provider/Repository : filtre type via sous-requete EXISTS (sans tronquer la collection embarquee), eager-load anti-N+1. Volet B — bouton « Filtres » sur la liste des categories (recherche nom + types multi en OR), sur le modele du Repertoire Clients ; etat local, jamais persiste en URL. Filtres back ?name= et ?typeId[]= sur la collection. Front : multi-select MalioSelectCheckbox, useCategoryForm en categoryTypeIds[], colonne « Types », i18n. ColumnCommentsCatalog + makefile test-db-setup alignes sur le nouvel index partiel. Tests Catalog/Commercial adaptes + CategoryFilterTest.
This commit is contained in:
@@ -420,17 +420,24 @@
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"type": "Type"
|
||||
"types": "Types"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"types": "Types de catégorie",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"type": "Type de catégorie",
|
||||
"typePlaceholder": "Sélectionner un type"
|
||||
"types": "Types de catégorie",
|
||||
"typesPlaceholder": "Sélectionner un ou plusieurs types"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typeRequired": "Le type de catégorie est obligatoire."
|
||||
"typesRequired": "Sélectionnez au moins un type de catégorie."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
@@ -440,7 +447,7 @@
|
||||
"created": "Catégorie créée avec succès",
|
||||
"updated": "Catégorie mise à jour avec succès",
|
||||
"deleted": "Catégorie supprimée avec succès",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||
number (categoryType id) ; conversion en IRI au moment du save
|
||||
par le composable useCategoryForm. -->
|
||||
<MalioSelect
|
||||
v-model="form.categoryTypeId.value"
|
||||
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox
|
||||
porte un tableau d'ids (categoryType id) ; conversion en tableau
|
||||
d'IRI au moment du save par le composable useCategoryForm. -->
|
||||
<MalioSelectCheckbox
|
||||
v-model="form.categoryTypeIds.value"
|
||||
:options="typeOptions"
|
||||
:label="t('admin.categories.form.type')"
|
||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||
:error="form.errors.categoryType"
|
||||
:label="t('admin.categories.form.types')"
|
||||
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
|
||||
:error="form.errors.categoryTypes"
|
||||
:display-tag="true"
|
||||
:disabled="loadingTypes"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
categoryTypes: [TYPE_VENTE],
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
expect(form.categoryTypeId.value).toBe(1)
|
||||
expect(form.categoryTypeIds.value).toEqual([1, 2])
|
||||
expect(form.errors).toEqual({})
|
||||
})
|
||||
|
||||
it('vide le formulaire en mode creation (null)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'old'
|
||||
form.categoryTypeId.value = 99
|
||||
form.categoryTypeIds.value = [99]
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
@@ -98,13 +98,32 @@ describe('useCategoryForm', () => {
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('passe a true quand on ajoute un type (selection multi)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reste false si la selection est identique dans un autre ordre', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
|
||||
form.categoryTypeIds.value = [2, 1]
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
@@ -115,7 +134,7 @@ describe('useCategoryForm', () => {
|
||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' '
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
@@ -126,7 +145,7 @@ describe('useCategoryForm', () => {
|
||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
@@ -137,7 +156,7 @@ describe('useCategoryForm', () => {
|
||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'.repeat(121)
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
@@ -145,21 +164,21 @@ describe('useCategoryForm', () => {
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = null
|
||||
form.categoryTypeIds.value = []
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et categoryType sont valides', () => {
|
||||
it('passe quand name et au moins un type sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
@@ -171,7 +190,7 @@ describe('useCategoryForm', () => {
|
||||
const form = useCategoryForm()
|
||||
// Erreur prealable : une validation en echec peuple errors.name.
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
form.validate()
|
||||
expect(form.errors.name).toBeTruthy()
|
||||
|
||||
@@ -184,17 +203,17 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
@@ -203,7 +222,7 @@ describe('useCategoryForm', () => {
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
@@ -215,7 +234,7 @@ describe('useCategoryForm', () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
@@ -231,7 +250,7 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
@@ -258,7 +277,7 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
@@ -269,24 +288,24 @@ describe('useCategoryForm', () => {
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.categoryType).toBe('Type invalide.')
|
||||
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
|
||||
})
|
||||
|
||||
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
|
||||
@@ -295,7 +314,7 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
@@ -314,7 +333,7 @@ describe('useCategoryForm', () => {
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
@@ -331,28 +350,28 @@ describe('useCategoryForm', () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||
form.name.value = 'Vis V2' // types inchanges
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeId.value = 2
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ categoryType: '/api/category_types/2' },
|
||||
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
@@ -438,7 +457,7 @@ describe('useCategoryForm', () => {
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
expect(form.errors).toEqual({})
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
|
||||
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
|
||||
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
|
||||
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
|
||||
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
|
||||
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
|
||||
* `name` + toast.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
@@ -42,20 +43,29 @@ export function useCategoryForm() {
|
||||
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||
const name = ref('')
|
||||
const categoryTypeId = ref<number | null>(null)
|
||||
const categoryTypeIds = ref<number[]>([])
|
||||
|
||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||
// que rien n'a change en mode consultation).
|
||||
const initialName = ref('')
|
||||
const initialCategoryTypeId = ref<number | null>(null)
|
||||
const initialCategoryTypeIds = ref<number[]>([])
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// Compare deux listes d'ids sans tenir compte de l'ordre (la selection
|
||||
// multi-types n'est pas ordonnee).
|
||||
function sameIds(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort((x, y) => x - y)
|
||||
const sortedB = [...b].sort((x, y) => x - y)
|
||||
return sortedA.every((v, i) => v === sortedB[i])
|
||||
}
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
name.value !== initialName.value
|
||||
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -66,15 +76,16 @@ export function useCategoryForm() {
|
||||
function loadFrom(category: Category | null): void {
|
||||
formErrors.clearErrors()
|
||||
if (category) {
|
||||
const ids = category.categoryTypes.map(t => t.id)
|
||||
name.value = category.name
|
||||
categoryTypeId.value = category.categoryType.id
|
||||
categoryTypeIds.value = [...ids]
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeId.value = category.categoryType.id
|
||||
initialCategoryTypeIds.value = [...ids]
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
initialCategoryTypeIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,23 +106,23 @@ export function useCategoryForm() {
|
||||
formErrors.setError('name', t('admin.categories.validation.nameLength'))
|
||||
}
|
||||
|
||||
// RG-1.05 — categoryType obligatoire.
|
||||
if (categoryTypeId.value === null) {
|
||||
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
|
||||
// RG-1.05 — au moins un type obligatoire.
|
||||
if (categoryTypeIds.value.length === 0) {
|
||||
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
|
||||
}
|
||||
|
||||
return !formErrors.errors.name && !formErrors.errors.categoryType
|
||||
return !formErrors.errors.name && !formErrors.errors.categoryTypes
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||
* Platform pour referencer une ressource liee.
|
||||
* Construit le payload POST a partir du state. Les `categoryTypes` sont
|
||||
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
|
||||
* API Platform pour referencer une collection de ressources liees.
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,8 +185,8 @@ export function useCategoryForm() {
|
||||
if (name.value !== initialName.value) {
|
||||
payload.name = name.value.trim()
|
||||
}
|
||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
|
||||
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
|
||||
}
|
||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||
@@ -233,9 +244,9 @@ export function useCategoryForm() {
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
initialCategoryTypeIds.value = []
|
||||
formErrors.clearErrors()
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -243,7 +254,7 @@ export function useCategoryForm() {
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeId,
|
||||
categoryTypeIds,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
|
||||
@@ -3,13 +3,28 @@
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme
|
||||
design que le Repertoire Clients). -->
|
||||
<div class="flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
<!-- Bouton Filtres a DROITE d'Ajouter. Le compteur reflete
|
||||
les filtres actifs. -->
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -47,6 +62,60 @@
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Appliquer ». Meme pattern que le Repertoire Clients. Etat 100 %
|
||||
local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.categories.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
|
||||
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
|
||||
elle porte AU MOINS UN des types coches (OR cote back). -->
|
||||
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in typeFilterOptions"
|
||||
:id="`filter-type-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftTypeIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('admin.categories.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.categories.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
const { types, fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
@@ -74,6 +143,7 @@ const {
|
||||
fetch: fetchCategories,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = usePaginatedList<Category>({ url: '/categories' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||
// on aplatit en label lisible pour l'affichage.
|
||||
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
|
||||
// aplatit en libelles joints par une virgule pour l'affichage.
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.categories.table.name') },
|
||||
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||
{ key: 'typesLabel', label: t('admin.categories.table.types') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
typeLabel: cat.categoryType?.label ?? '',
|
||||
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
|
||||
})),
|
||||
)
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern Repertoire Clients) :
|
||||
// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au
|
||||
// clic « Appliquer » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftTypeIds = ref<number[]>([])
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedTypeIds = ref<number[]>([])
|
||||
|
||||
// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
|
||||
const typeFilterOptions = computed(() =>
|
||||
types.value.map(ct => ({ value: ct.id, label: ct.label })),
|
||||
)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedTypeIds.value.length > 0) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('admin.categories.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftTypeIds.value = [...appliedTypeIds.value]
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleType(id: number, selected: boolean): void {
|
||||
draftTypeIds.value = selected
|
||||
? [...draftTypeIds.value, id]
|
||||
: draftTypeIds.value.filter(t => t !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[]> {
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
|
||||
if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||
// page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedTypeIds.value = [...draftTypeIds.value]
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftTypeIds.value = []
|
||||
appliedSearch.value = ''
|
||||
appliedTypeIds.value = []
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
function getCategoryById(id: number): Category | undefined {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - POST /api/categories → body { name, categoryType: IRI }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||
* - POST /api/categories → body { name, categoryTypes: IRI[] }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
|
||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||
*
|
||||
* Notes :
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
|
||||
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType) : tableau d'objets type en lecture.
|
||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||
*/
|
||||
@@ -43,7 +43,8 @@ export interface CategoryType {
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
categoryType: CategoryType
|
||||
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
|
||||
categoryTypes: CategoryType[]
|
||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
@@ -53,12 +54,12 @@ export interface Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||
* IRI Hydra (ex. `/api/category_types/3`).
|
||||
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
|
||||
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
|
||||
*/
|
||||
export interface CategoryCreateInput {
|
||||
name: string
|
||||
categoryType: string
|
||||
categoryTypes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryType?: string
|
||||
categoryTypes?: string[]
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Catalog — Category multi-types : passage de la relation Category -> CategoryType
|
||||
* de ManyToOne a ManyToMany.
|
||||
*
|
||||
* Ordre critique :
|
||||
* 1. Creation de la table de jonction `category_category_type` (FK category ON
|
||||
* DELETE CASCADE, FK category_type ON DELETE RESTRICT — conserve le garde-fou
|
||||
* « on ne supprime pas un type encore reference »).
|
||||
* 2. Backfill : chaque categorie existante recoit une ligne de jonction vers son
|
||||
* ancien `category_type_id` (avant de dropper la colonne).
|
||||
* 3. Drop de l'index unique (LOWER(name), category_type_id), de l'index FK et de
|
||||
* la colonne `category.category_type_id` (Postgres drope la FK dependante).
|
||||
* 4. Nouvel index unique GLOBAL sur le nom : LOWER(name) WHERE deleted_at IS NULL
|
||||
* (l'unicite n'est plus liee au type — RG-1.07 reformulee).
|
||||
*
|
||||
* Sur base fraiche, les categories seedees CLIENT (Distributeur/Courtier/Secteur/
|
||||
* Autre) et FOURNISSEUR (Negociant/Cooperative/...) n'ont aucun nom en collision
|
||||
* -> l'index unique global passe sans conflit.
|
||||
*
|
||||
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) :
|
||||
* Doctrine Migrations 3.x trie par FQCN puis version ; le namespace racine garantit
|
||||
* l'ordre par timestamp apres les migrations d'init des tables.
|
||||
*/
|
||||
final class Version20260608120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Catalog : Category <-> CategoryType en ManyToMany (jonction category_category_type), unicite du nom globalisee.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Table de jonction.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE category_category_type (
|
||||
category_id INT NOT NULL,
|
||||
category_type_id INT NOT NULL,
|
||||
PRIMARY KEY (category_id, category_type_id),
|
||||
CONSTRAINT fk_category_category_type_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_category_category_type_type
|
||||
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_cat_cat_type_type ON category_category_type (category_type_id)');
|
||||
|
||||
$this->comment('category_category_type', '_table', 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).');
|
||||
$this->comment('category_category_type', 'category_id', 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.');
|
||||
$this->comment('category_category_type', 'category_type_id', 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).');
|
||||
|
||||
// 2. Backfill depuis l'ancienne colonne ManyToOne (chaque categorie -> 1 type).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_category_type (category_id, category_type_id)
|
||||
SELECT id, category_type_id FROM category
|
||||
SQL);
|
||||
|
||||
// 3. Suppression de l'ancien modele : index unique par type, index FK, colonne.
|
||||
$this->addSql('DROP INDEX uq_category_name_type_active');
|
||||
$this->addSql('DROP INDEX idx_category_type_id');
|
||||
// DROP COLUMN drope automatiquement la FK fk_category_type qui en depend.
|
||||
$this->addSql('ALTER TABLE category DROP COLUMN category_type_id');
|
||||
|
||||
// 4. Unicite du nom desormais GLOBALE parmi les actifs (RG-1.07 reformulee).
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_category_name_active
|
||||
ON category (LOWER(name))
|
||||
WHERE deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
// Realignement de la doc SQL de `category` (le type n'est plus une colonne).
|
||||
$this->comment('category', '_table', 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).');
|
||||
$this->comment('category', 'name', 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Restauration best-effort de l'ancien modele ManyToOne (1 type par categorie).
|
||||
$this->addSql('DROP INDEX IF EXISTS uq_category_name_active');
|
||||
|
||||
$this->addSql('ALTER TABLE category ADD COLUMN category_type_id INT DEFAULT NULL');
|
||||
|
||||
// Reprend le premier type de chaque categorie (l'ordre des types perdus
|
||||
// au-dela du premier est best-effort : le modele cible n'en gardait qu'un).
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE category c
|
||||
SET category_type_id = (
|
||||
SELECT cct.category_type_id
|
||||
FROM category_category_type cct
|
||||
WHERE cct.category_id = c.id
|
||||
ORDER BY cct.category_type_id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Categories sans aucun type (theorique) : on les rattache a defaut au
|
||||
// premier type existant pour pouvoir reposer le NOT NULL.
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE category
|
||||
SET category_type_id = (SELECT id FROM category_type ORDER BY id ASC LIMIT 1)
|
||||
WHERE category_type_id IS NULL
|
||||
SQL);
|
||||
|
||||
$this->addSql('ALTER TABLE category ALTER COLUMN category_type_id SET NOT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE category
|
||||
ADD CONSTRAINT fk_category_type
|
||||
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_category_name_type_active
|
||||
ON category (LOWER(name), category_type_id)
|
||||
WHERE deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->addSql('DROP TABLE category_category_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
||||
* (FK vers le referentiel statique CategoryType).
|
||||
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs
|
||||
* `categoryTypes` (ManyToMany vers le referentiel statique CategoryType,
|
||||
* table de jonction `category_category_type`). Une categorie peut appartenir
|
||||
* a plusieurs types simultanement (>= 1 obligatoire, RG-1.05).
|
||||
*
|
||||
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
||||
@@ -81,12 +85,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||
#[ORM\Table(name: 'category')]
|
||||
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
||||
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
|
||||
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
|
||||
// index partiel via attribut.
|
||||
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS
|
||||
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code
|
||||
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine
|
||||
// ORM ne sait pas exprimer un index partiel via attribut.
|
||||
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
||||
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
@@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
#[Groups(['category:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||
/**
|
||||
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le
|
||||
* referentiel statique CategoryType via la jonction `category_category_type`.
|
||||
* Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre
|
||||
* supprime tant qu'il reste reference par une categorie.
|
||||
*
|
||||
* @var Collection<int, CategoryType>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: CategoryType::class)]
|
||||
#[ORM\JoinTable(name: 'category_category_type')]
|
||||
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
private ?CategoryType $categoryType = null;
|
||||
private Collection $categoryTypes;
|
||||
|
||||
/**
|
||||
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||
@@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
#[Groups(['category:read'])]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categoryTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategoryType(): ?CategoryType
|
||||
/**
|
||||
* @return Collection<int, CategoryType>
|
||||
*/
|
||||
public function getCategoryTypes(): Collection
|
||||
{
|
||||
return $this->categoryType;
|
||||
return $this->categoryTypes;
|
||||
}
|
||||
|
||||
public function setCategoryType(?CategoryType $categoryType): static
|
||||
public function addCategoryType(CategoryType $categoryType): static
|
||||
{
|
||||
$this->categoryType = $categoryType;
|
||||
if (!$this->categoryTypes->contains($categoryType)) {
|
||||
$this->categoryTypes->add($categoryType);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategoryType(CategoryType $categoryType): static
|
||||
{
|
||||
$this->categoryTypes->removeElement($categoryType);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemente CategoryInterface : code du type rattache (ou null). Permet
|
||||
* aux modules tiers de filtrer/valider par type metier sans dependre de
|
||||
* Catalog.
|
||||
* Implemente CategoryInterface : liste des codes de types rattaches a la
|
||||
* categorie. Permet aux modules tiers de filtrer/valider par type metier
|
||||
* (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getCategoryTypeCode(): ?string
|
||||
public function getCategoryTypeCodes(): array
|
||||
{
|
||||
return $this->categoryType?->getCode();
|
||||
return array_values(array_filter(
|
||||
$this->categoryTypes->map(static fn (CategoryType $t): ?string => $t->getCode())->toArray(),
|
||||
));
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
|
||||
@@ -23,10 +23,26 @@ interface CategoryRepositoryInterface
|
||||
/**
|
||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||
* - $typeCode non null : ne garde que les categories dont le CategoryType
|
||||
* porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
|
||||
* multi-select Categorie du fournisseur (M2, RG-2.10).
|
||||
* - $typeCode non null : ne garde que les categories PORTANT ce code de type
|
||||
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select
|
||||
* Categorie du fournisseur (M2, RG-2.10).
|
||||
* - $nameSearch non null : recherche partielle case-insensitive sur le nom
|
||||
* (filtre `?name=` de la liste admin).
|
||||
* - $typeIds non vide : ne garde que les categories portant AU MOINS UN des
|
||||
* types (OR, filtre `?typeId[]=` de la liste admin).
|
||||
* - Tri : name ASC (RG-1.10).
|
||||
*
|
||||
* Les categories etant en ManyToMany avec leurs types, la collection
|
||||
* `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la
|
||||
* serialisation, et `distinct` est applique des qu'un filtre type joint la
|
||||
* table de jonction (evite les lignes dupliquees).
|
||||
*
|
||||
* @param list<int> $typeIds
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder;
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $typeCode = null,
|
||||
?string $nameSearch = null,
|
||||
array $typeIds = [],
|
||||
): QueryBuilder;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
|
||||
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
|
||||
* UniqueConstraintViolationException remontee par Postgres (collision sur
|
||||
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
|
||||
* le message attendu par la spec (RG-1.07).
|
||||
* l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les
|
||||
* actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07).
|
||||
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
|
||||
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||
@@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
|
||||
// RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted
|
||||
// (uq_category_name_active). L'unicite n'est plus liee au type depuis le
|
||||
// passage en ManyToMany.
|
||||
throw new HttpException(
|
||||
409,
|
||||
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
|
||||
sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
$qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context));
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeDeleted,
|
||||
$this->readTypeCode($context),
|
||||
$this->readNameSearch($context),
|
||||
$this->readTypeIds($context),
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
||||
@@ -115,4 +120,48 @@ final class CategoryProvider implements ProviderInterface
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?name=` (recherche partielle sur le nom, liste admin).
|
||||
* Renvoie la valeur trimmee ou null si absente / vide.
|
||||
*/
|
||||
private function readNameSearch(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['name'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?typeId[]=` (liste admin) : ids des types coches (OR).
|
||||
* Tolere une valeur scalaire unique (`?typeId=3`) ou un tableau. Ignore
|
||||
* les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readTypeIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['typeId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
$category = new Category();
|
||||
$category->setName($name);
|
||||
$category->setCode($code);
|
||||
$category->setCategoryType($type);
|
||||
$category->addCategoryType($type);
|
||||
$manager->persist($category);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,19 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
||||
return [] !== $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeDeleted = false, ?string $typeCode = null): QueryBuilder
|
||||
{
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $typeCode = null,
|
||||
?string $nameSearch = null,
|
||||
array $typeIds = [],
|
||||
): QueryBuilder {
|
||||
// Eager-load de la collection categoryTypes (ManyToMany) : embarquee a la
|
||||
// serialisation -> on la fetch-joine pour eviter un N+1 par categorie. Le
|
||||
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
|
||||
// compatible avec ce fetch-join to-many.
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->leftJoin('c.categoryTypes', 'cte')
|
||||
->addSelect('cte')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
@@ -58,16 +68,45 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
||||
$qb->andWhere('c.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
// Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
|
||||
// les categories du type demande (ex. FOURNISSEUR). La jointure reste
|
||||
// compatible avec le Paginator ORM (fetchJoinCollection) du provider.
|
||||
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10,
|
||||
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS
|
||||
// restreindre la collection eager-loadee `cte` (sinon les autres types de
|
||||
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
|
||||
if (null !== $typeCode) {
|
||||
$qb->join('c.categoryType', 'ct')
|
||||
->andWhere('ct.code = :typeCode')
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Category::class, 'c_tc')
|
||||
->join('c_tc.categoryTypes', 'ct_tc')
|
||||
->where('c_tc = c')
|
||||
->andWhere('ct_tc.code = :typeCode')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('typeCode', $typeCode)
|
||||
;
|
||||
}
|
||||
|
||||
// Filtre `?typeId[]=` (liste admin) : la categorie porte AU MOINS UN des
|
||||
// types coches (OR). Meme strategie EXISTS correlee que `typeCode`.
|
||||
if ([] !== $typeIds) {
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Category::class, 'c_ti')
|
||||
->join('c_ti.categoryTypes', 'ct_ti')
|
||||
->where('c_ti = c')
|
||||
->andWhere('ct_ti.id IN (:typeIds)')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('typeIds', $typeIds)
|
||||
;
|
||||
}
|
||||
|
||||
// Filtre `?name=` (liste admin) : recherche partielle case-insensitive.
|
||||
if (null !== $nameSearch && '' !== $nameSearch) {
|
||||
$qb->andWhere('LOWER(c.name) LIKE :nameSearch')
|
||||
->setParameter('nameSearch', '%'.mb_strtolower($nameSearch).'%')
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur le
|
||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur le
|
||||
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas d'import du
|
||||
* module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
@@ -300,16 +300,17 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
|
||||
* POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
* Platform, sur POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
|
||||
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
|
||||
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
|
||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
||||
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
|
||||
@@ -219,15 +219,16 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
|
||||
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
|
||||
@@ -421,11 +421,12 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
||||
return $this->categoryCache[$name];
|
||||
}
|
||||
|
||||
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par
|
||||
// le seul `name` rattacherait une categorie homonyme d'un autre type (ex.
|
||||
// futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une
|
||||
// categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP
|
||||
// (findBy ne sait pas filtrer une propriete imbriquee categoryType.code).
|
||||
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
|
||||
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
|
||||
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
|
||||
// plus une categorie, mais on conserve la verification du type pour
|
||||
// ecarter un homonyme qui ne porterait pas FOURNISSEUR. Le filtre type
|
||||
// est porte cote PHP (findBy ne sait pas filtrer la collection categoryTypes).
|
||||
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
|
||||
'name' => $name,
|
||||
'deletedAt' => null,
|
||||
@@ -433,7 +434,7 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate instanceof CategoryInterface
|
||||
&& self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) {
|
||||
&& in_array(self::SUPPLIER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
|
||||
return $this->categoryCache[$name] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,14 @@ interface CategoryInterface
|
||||
public function getCode(): ?string;
|
||||
|
||||
/**
|
||||
* Code du type de categorie rattache (CategoryType::code), ou null si la
|
||||
* categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
|
||||
* type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
|
||||
* Conserve pour l'affichage / la retrocompatibilite.
|
||||
* Codes des types de categorie rattaches (CategoryType::code), tableau vide
|
||||
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter
|
||||
* plusieurs types : un module tiers teste l'appartenance via
|
||||
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote
|
||||
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
|
||||
* FOURNISSEUR).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getCategoryTypeCode(): ?string;
|
||||
public function getCategoryTypeCodes(): array;
|
||||
}
|
||||
|
||||
@@ -50,12 +50,11 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'category' => [
|
||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
|
||||
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||
'_table' => 'Categories — referentiel multi-types via la jonction category_category_type, soft-delete via deleted_at, unicite LOWER(name) GLOBALE parmi les actifs (uq_category_name_active).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique GLOBALEMENT parmi les actifs (RG-1.07, uq_category_name_active).',
|
||||
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
|
||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'category_type' => [
|
||||
@@ -65,6 +64,12 @@ final class ColumnCommentsCatalog
|
||||
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
||||
],
|
||||
|
||||
'category_category_type' => [
|
||||
'_table' => 'Jointure M2M category <-> category_type (Catalog) — types portes par la categorie, au moins un obligatoire (RG-1.05).',
|
||||
'category_id' => 'FK -> category.id, ON DELETE CASCADE — categorie portant le type.',
|
||||
'category_type_id' => 'FK -> category_type.id, ON DELETE RESTRICT — type rattache (un type ne peut etre supprime tant qu il reste reference).',
|
||||
],
|
||||
|
||||
'permission' => [
|
||||
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
|
||||
@@ -70,11 +70,17 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
||||
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
|
||||
* Le flag $deletedAt permet de seeder directement une categorie
|
||||
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
|
||||
*
|
||||
* Multi-types (ManyToMany) : `$type` est le type principal (cree si null) ;
|
||||
* `$additionalTypes` permet d'attacher d'autres types pour les cas multi.
|
||||
*
|
||||
* @param list<CategoryType> $additionalTypes
|
||||
*/
|
||||
protected function createCategory(
|
||||
?string $name = null,
|
||||
?CategoryType $type = null,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
array $additionalTypes = [],
|
||||
): Category {
|
||||
$em = $this->getEm();
|
||||
|
||||
@@ -86,7 +92,10 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
||||
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
|
||||
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
|
||||
$category->setCode('TEST_'.strtoupper($suffix));
|
||||
$category->setCategoryType($type);
|
||||
$category->addCategoryType($type);
|
||||
foreach ($additionalTypes as $additionalType) {
|
||||
$category->addCategoryType($additionalType);
|
||||
}
|
||||
if (null !== $deletedAt) {
|
||||
$category->setDeletedAt($deletedAt);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
@@ -139,7 +139,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
|
||||
@@ -26,7 +26,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
@@ -48,7 +48,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
// Le client tente d'imposer un code : doit etre ignore.
|
||||
'code' => 'CLIENT_FORGED',
|
||||
],
|
||||
@@ -65,13 +65,13 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Deux noms differents (donc autorises par uq_category_name_type_active)
|
||||
// Deux noms differents (donc autorises par uq_category_name_active)
|
||||
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
|
||||
$first = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
@@ -79,7 +79,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Tests des filtres de la liste admin sur GET /api/categories :
|
||||
* - `?name=` : recherche partielle case-insensitive sur le nom ;
|
||||
* - `?typeId[]=` : categories portant AU MOINS UN des types coches (OR), sans
|
||||
* doublon meme pour une categorie multi-types ;
|
||||
* - combinaison `?name=` + `?typeId[]=` (ET entre filtres).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryFilterTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $members
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function testNames(array $members): array
|
||||
{
|
||||
$names = array_map(static fn (array $m): string => $m['name'], $members);
|
||||
$names = array_values(array_filter(
|
||||
$names,
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
sort($names);
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
public function testNameFilterIsPartialAndCaseInsensitive(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Acier inox', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'Aluminium', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?name=ACIER&pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
self::assertSame(
|
||||
[self::TEST_CATEGORY_PREFIX.'Acier inox'],
|
||||
$this->testNames($response->toArray()['member']),
|
||||
'Le filtre ?name= doit etre partiel et insensible a la casse.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testTypeIdFilterReturnsCategoriesWithAtLeastOneType(): void
|
||||
{
|
||||
$typeA = $this->createCategoryType();
|
||||
$typeB = $this->createCategoryType();
|
||||
$typeC = $this->createCategoryType();
|
||||
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_a', $typeA);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_b', $typeB);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'only_c', $typeC);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request(
|
||||
'GET',
|
||||
sprintf('/api/categories?typeId[]=%d&typeId[]=%d&pagination=false', $typeA->getId(), $typeB->getId()),
|
||||
);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
self::TEST_CATEGORY_PREFIX.'only_a',
|
||||
self::TEST_CATEGORY_PREFIX.'only_b',
|
||||
],
|
||||
$this->testNames($response->toArray()['member']),
|
||||
'Le filtre ?typeId[]= doit remonter les categories portant AU MOINS UN des types (OR).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testMultiTypeCategoryAppearsOnceWhenFilteredByOneType(): void
|
||||
{
|
||||
// Une categorie portant deux types ne doit pas etre dupliquee quand on
|
||||
// filtre sur l'un de ses types (la sous-requete EXISTS evite les doublons).
|
||||
$typeA = $this->createCategoryType();
|
||||
$typeB = $this->createCategoryType();
|
||||
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'multi',
|
||||
$typeA,
|
||||
null,
|
||||
[$typeB],
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request(
|
||||
'GET',
|
||||
sprintf('/api/categories?typeId[]=%d&pagination=false', $typeA->getId()),
|
||||
);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$members = $response->toArray()['member'];
|
||||
self::assertSame(
|
||||
[self::TEST_CATEGORY_PREFIX.'multi'],
|
||||
$this->testNames($members),
|
||||
'La categorie multi-types ne doit apparaitre qu une seule fois.',
|
||||
);
|
||||
|
||||
// Les deux types restent embarques (la collection n'est pas tronquee).
|
||||
$multi = array_values(array_filter(
|
||||
$members,
|
||||
fn (array $m): bool => $m['name'] === self::TEST_CATEGORY_PREFIX.'multi',
|
||||
))[0];
|
||||
self::assertCount(2, $multi['categoryTypes'], 'Les 2 types doivent rester embarques malgre le filtre.');
|
||||
}
|
||||
|
||||
public function testNameAndTypeIdFiltersCombine(): void
|
||||
{
|
||||
$typeA = $this->createCategoryType();
|
||||
$typeB = $this->createCategoryType();
|
||||
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_a', $typeA);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'steel_b', $typeB);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'wood_a', $typeA);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request(
|
||||
'GET',
|
||||
sprintf('/api/categories?name=steel&typeId[]=%d&pagination=false', $typeA->getId()),
|
||||
);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
self::assertSame(
|
||||
[self::TEST_CATEGORY_PREFIX.'steel_a'],
|
||||
$this->testNames($response->toArray()['member']),
|
||||
'Les filtres ?name= et ?typeId[]= doivent se combiner (ET).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -81,7 +81,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'anon',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -96,7 +96,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -112,7 +112,7 @@ final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
@@ -140,7 +140,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
@@ -220,7 +220,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
|
||||
@@ -47,9 +47,10 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
|
||||
'Le filtre ?typeCode= doit ne renvoyer QUE les categories du type demande.',
|
||||
);
|
||||
|
||||
// Tous les types embarques doivent etre le type filtre.
|
||||
// Chaque categorie remontee doit PORTER le type filtre (multi-types :
|
||||
// la collection categoryTypes embarquee contient le code demande).
|
||||
foreach ($members as $member) {
|
||||
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
|
||||
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ final class CategoryTypeCodeFilterTest extends AbstractCatalogApiTestCase
|
||||
self::assertArrayHasKey('member', $data);
|
||||
|
||||
foreach ($data['member'] as $member) {
|
||||
self::assertSame('TEST_FOURNISSEUR', $member['categoryType']['code']);
|
||||
self::assertContains('TEST_FOURNISSEUR', array_column($member['categoryTypes'], 'code'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,22 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
|
||||
* parmi les categories non soft-deleted. L'index Postgres partiel
|
||||
* `uq_category_name_type_active` est traduit en 409 Conflict par le
|
||||
* CategoryProcessor.
|
||||
* Tests RG-1.07 : unicite case-insensitive du nom GLOBALEMENT (LOWER(name))
|
||||
* parmi les categories non soft-deleted. Depuis le passage en ManyToMany,
|
||||
* l'unicite n'est plus liee au type. L'index Postgres partiel
|
||||
* `uq_category_name_active` est traduit en 409 Conflict par le CategoryProcessor.
|
||||
*
|
||||
* Cas couverts :
|
||||
* - doublon strict (meme name + meme type) → 409 ;
|
||||
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
|
||||
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
|
||||
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
|
||||
* - doublon strict (meme name) → 409 ;
|
||||
* - doublon case-insensitive (Vis / VIS) → 409 ;
|
||||
* - meme name avec des types differents → 409 (unicite GLOBALE) ;
|
||||
* - recreation apres soft delete → 201 (l'index partiel libere le nom).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testDuplicateNameSameTypeReturns409(): void
|
||||
public function testDuplicateNameReturns409(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
@@ -29,29 +29,29 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// 2eme POST : meme name + meme type → doublon strict.
|
||||
// 2eme POST : meme name → doublon (unicite globale).
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
|
||||
// Message attendu par la spec RG-1.07.
|
||||
// Message attendu par la spec RG-1.07 (reformulee, sans "pour ce type").
|
||||
$payload = $response->toArray(false);
|
||||
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
||||
self::assertStringContainsString(
|
||||
'existe déjà pour ce type',
|
||||
'existe déjà',
|
||||
$description,
|
||||
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
|
||||
'Le message d\'erreur 409 doit citer la spec ("existe deja").',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
@@ -74,17 +74,17 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
// Meme prefix mais variation de casse → meme LOWER → collision.
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSameNameDifferentTypeAllowed(): void
|
||||
public function testSameNameDifferentTypeReturns409(): void
|
||||
{
|
||||
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
|
||||
// Le meme nom doit etre acceptable sur deux types differents.
|
||||
// RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le
|
||||
// meme nom sur deux types differents est un doublon.
|
||||
$type1 = $this->createCategoryType();
|
||||
$type2 = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
@@ -92,27 +92,27 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type1->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryTypes' => ['/api/category_types/'.$type1->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type2->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryTypes' => ['/api/category_types/'.$type2->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRecreateAfterSoftDeleteAllowed(): void
|
||||
{
|
||||
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
|
||||
// Apres un soft delete, le couple (name, type) est libere et un
|
||||
// nouveau POST identique doit reussir.
|
||||
// Apres un soft delete, le nom est libere et un nouveau POST identique
|
||||
// doit reussir.
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
@@ -120,8 +120,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
@@ -131,12 +131,12 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
$client->request('DELETE', '/api/categories/'.$created['id']);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// 3) recreation : meme name + meme type → autorise (couple libere).
|
||||
// 3) recreation : meme name → autorise (nom libere par l'archivage).
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
@@ -11,8 +11,8 @@ use App\Module\Catalog\Domain\Entity\Category;
|
||||
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
||||
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
||||
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
||||
* - RG-1.05 : `categoryType` obligatoire ;
|
||||
* - RG-1.06 : `categoryType` doit pointer un type existant.
|
||||
* - RG-1.05 : `categoryTypes` — au moins un type (Count min 1) ;
|
||||
* - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
// name absent
|
||||
],
|
||||
]);
|
||||
@@ -42,8 +42,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => '',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => '',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -59,8 +59,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => ' ',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => ' ',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -79,8 +79,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $payloadName,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => $payloadName,
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -103,8 +103,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'A',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => 'A',
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -118,8 +118,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => str_repeat('a', 121),
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => str_repeat('a', 121),
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -140,71 +140,74 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $name,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
'name' => $name,
|
||||
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
// ============ RG-1.05 — categoryType obligatoire ============
|
||||
// ============ RG-1.05 — au moins un type (Count min 1) ============
|
||||
|
||||
public function testCategoryTypeRequiredReturns422(): void
|
||||
public function testCategoryTypesRequiredReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
||||
// categoryType absent
|
||||
// categoryTypes absent -> collection vide -> Count(min:1) viole.
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testCategoryTypeNullIsRejected(): void
|
||||
public function testCategoryTypesEmptyReturns422(): void
|
||||
{
|
||||
// `categoryType: null` echoue a la deserialization IRI (API Platform
|
||||
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
|
||||
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
|
||||
// Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec
|
||||
// une violation sur le propertyPath `categoryTypes` (consommable inline).
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
|
||||
'categoryType' => null,
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'empty_types',
|
||||
'categoryTypes' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
$payload = $response->toArray(false);
|
||||
$violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
|
||||
$paths = array_column($violations, 'propertyPath');
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 422],
|
||||
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
|
||||
'categoryTypes',
|
||||
$paths,
|
||||
'La violation Count doit porter le propertyPath `categoryTypes`.',
|
||||
);
|
||||
}
|
||||
|
||||
// ============ RG-1.06 — categoryType doit exister ============
|
||||
// ============ RG-1.06 — chaque type doit exister ============
|
||||
|
||||
public function testCategoryTypeMustExistReturns4xx(): void
|
||||
{
|
||||
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
||||
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
|
||||
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
|
||||
// (resolution IRI echouee) ou 422 (validation declenchee). La spec § 4.3
|
||||
// accepte les deux : on assert le contrat "ne passe pas".
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||
'categoryType' => '/api/category_types/9999999',
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||
'categoryTypes' => ['/api/category_types/9999999'],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 404, 422],
|
||||
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
|
||||
'IRI categoryTypes inexistante doit etre rejetee (400/404/422 selon API Platform).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
$category = new Category();
|
||||
$category->setName($name);
|
||||
$category->setCode($effectiveCode);
|
||||
$category->setCategoryType($this->clientCategoryType());
|
||||
$category->addCategoryType($this->clientCategoryType());
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
|
||||
$category->setCode($code);
|
||||
$category->setCategoryType($this->supplierCategoryType());
|
||||
$category->addCategoryType($this->supplierCategoryType());
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ final class SupplierValidationTest extends TestCase
|
||||
self::assertContains('categories', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testMultiTypeCategoryContainingFournisseurIsAccepted(): void
|
||||
{
|
||||
// RG-2.10 sous ManyToMany : une categorie qui PORTE FOURNISSEUR (parmi
|
||||
// d'autres types) reste autorisee sur un fournisseur.
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->addCategory($this->category('CLIENT', 'FOURNISSEUR'));
|
||||
|
||||
self::assertNotContains('categories', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
// === RG-2.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankIsRejectedOnBankPath(): void
|
||||
@@ -131,13 +142,17 @@ final class SupplierValidationTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
|
||||
* type de categorie voulu — seul element regarde par validateCategoryType.
|
||||
* Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de
|
||||
* type voulus — seul element regarde par validateCategoryType. Variadic pour
|
||||
* couvrir le cas multi-types (ManyToMany).
|
||||
*
|
||||
* @return list<string> n'est pas le type de retour : helper renvoyant un double
|
||||
*/
|
||||
private function category(string $typeCode): CategoryInterface
|
||||
private function category(string ...$typeCodes): CategoryInterface
|
||||
{
|
||||
return new class($typeCode) implements CategoryInterface {
|
||||
public function __construct(private readonly string $typeCode) {}
|
||||
return new class(array_values($typeCodes)) implements CategoryInterface {
|
||||
/** @param list<string> $typeCodes */
|
||||
public function __construct(private readonly array $typeCodes) {}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
@@ -154,9 +169,10 @@ final class SupplierValidationTest extends TestCase
|
||||
return 'TEST';
|
||||
}
|
||||
|
||||
public function getCategoryTypeCode(): ?string
|
||||
/** @return list<string> */
|
||||
public function getCategoryTypeCodes(): array
|
||||
{
|
||||
return $this->typeCode;
|
||||
return $this->typeCodes;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user