feat(catalog) : categories multi-types (M:N) + filtres liste
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 51s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m9s

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:
Matthieu
2026-06-08 11:26:07 +02:00
parent 43b2251ef1
commit f813c1732e
31 changed files with 909 additions and 257 deletions
+12 -5
View File
@@ -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)
}
+11 -10
View File
@@ -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[]
}
+1 -1
View File
@@ -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"
+149
View File
@@ -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,
));
}
}
+54 -20
View File
@@ -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'));
}
}
+37 -37
View File
@@ -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;
}
};
}