Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 843e4b0a0c | |||
| a9c14704b7 |
@@ -75,7 +75,7 @@ jobs:
|
|||||||
- name: Bootstrap test database
|
- name: Bootstrap test database
|
||||||
# Aligne sur la cible `test-db-setup` du makefile : apres
|
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||||
# `schema:update --force`, on RECREE manuellement l'index unique
|
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||||
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
# partiel `uq_category_name_active` car Doctrine ORM ne sait
|
||||||
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||||
# deleted_at IS NULL) et `schema:update` les considere comme
|
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||||
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
php bin/console app:apply-column-comments --env=test --no-interaction
|
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
php bin/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"
|
php bin/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"
|
||||||
|
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.95'
|
app.version: '0.1.96'
|
||||||
|
|||||||
@@ -420,17 +420,24 @@
|
|||||||
"noCategories": "Aucune catégorie pour l'instant.",
|
"noCategories": "Aucune catégorie pour l'instant.",
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Nom",
|
"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": {
|
"form": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"type": "Type de catégorie",
|
"types": "Types de catégorie",
|
||||||
"typePlaceholder": "Sélectionner un type"
|
"typesPlaceholder": "Sélectionner un ou plusieurs types"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Le nom est obligatoire.",
|
"nameRequired": "Le nom est obligatoire.",
|
||||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
"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": {
|
"delete": {
|
||||||
"title": "Supprimer la catégorie",
|
"title": "Supprimer la catégorie",
|
||||||
@@ -440,7 +447,7 @@
|
|||||||
"created": "Catégorie créée avec succès",
|
"created": "Catégorie créée avec succès",
|
||||||
"updated": "Catégorie mise à jour avec succès",
|
"updated": "Catégorie mise à jour avec succès",
|
||||||
"deleted": "Catégorie supprimée 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."
|
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,18 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox
|
||||||
number (categoryType id) ; conversion en IRI au moment du save
|
porte un tableau d'ids (categoryType id) ; conversion en tableau
|
||||||
par le composable useCategoryForm. -->
|
d'IRI au moment du save par le composable useCategoryForm. -->
|
||||||
<MalioSelect
|
<MalioSelectCheckbox
|
||||||
v-model="form.categoryTypeId.value"
|
v-model="form.categoryTypeIds.value"
|
||||||
:options="typeOptions"
|
:options="typeOptions"
|
||||||
:label="t('admin.categories.form.type')"
|
:label="t('admin.categories.form.types')"
|
||||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
|
||||||
:error="form.errors.categoryType"
|
:error="form.errors.categoryTypes"
|
||||||
|
:display-tag="true"
|
||||||
:disabled="loadingTypes"
|
:disabled="loadingTypes"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
|||||||
const CAT: Category = {
|
const CAT: Category = {
|
||||||
id: 42,
|
id: 42,
|
||||||
name: 'Vis',
|
name: 'Vis',
|
||||||
categoryType: TYPE_VENTE,
|
categoryTypes: [TYPE_VENTE],
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
createdAt: '2026-01-01T10:00:00+00:00',
|
createdAt: '2026-01-01T10:00:00+00:00',
|
||||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||||
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('loadFrom', () => {
|
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()
|
const form = useCategoryForm()
|
||||||
|
|
||||||
form.loadFrom(CAT)
|
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||||
|
|
||||||
expect(form.name.value).toBe('Vis')
|
expect(form.name.value).toBe('Vis')
|
||||||
expect(form.categoryTypeId.value).toBe(1)
|
expect(form.categoryTypeIds.value).toEqual([1, 2])
|
||||||
expect(form.errors).toEqual({})
|
expect(form.errors).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('vide le formulaire en mode creation (null)', () => {
|
it('vide le formulaire en mode creation (null)', () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'old'
|
form.name.value = 'old'
|
||||||
form.categoryTypeId.value = 99
|
form.categoryTypeIds.value = [99]
|
||||||
|
|
||||||
form.loadFrom(null)
|
form.loadFrom(null)
|
||||||
|
|
||||||
expect(form.name.value).toBe('')
|
expect(form.name.value).toBe('')
|
||||||
expect(form.categoryTypeId.value).toBeNull()
|
expect(form.categoryTypeIds.value).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||||
@@ -98,13 +98,32 @@ describe('useCategoryForm', () => {
|
|||||||
|
|
||||||
expect(form.isDirty.value).toBe(true)
|
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', () => {
|
describe('validate', () => {
|
||||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = ''
|
form.name.value = ''
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
@@ -115,7 +134,7 @@ describe('useCategoryForm', () => {
|
|||||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = ' '
|
form.name.value = ' '
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
@@ -126,7 +145,7 @@ describe('useCategoryForm', () => {
|
|||||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'A'
|
form.name.value = 'A'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
@@ -137,7 +156,7 @@ describe('useCategoryForm', () => {
|
|||||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'A'.repeat(121)
|
form.name.value = 'A'.repeat(121)
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
@@ -145,21 +164,21 @@ describe('useCategoryForm', () => {
|
|||||||
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
|
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()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = null
|
form.categoryTypeIds.value = []
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
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()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1, 2]
|
||||||
|
|
||||||
const ok = form.validate()
|
const ok = form.validate()
|
||||||
|
|
||||||
@@ -171,7 +190,7 @@ describe('useCategoryForm', () => {
|
|||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
// Erreur prealable : une validation en echec peuple errors.name.
|
// Erreur prealable : une validation en echec peuple errors.name.
|
||||||
form.name.value = ''
|
form.name.value = ''
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
form.validate()
|
form.validate()
|
||||||
expect(form.errors.name).toBeTruthy()
|
expect(form.errors.name).toBeTruthy()
|
||||||
|
|
||||||
@@ -184,17 +203,17 @@ describe('useCategoryForm', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('submitCreate', () => {
|
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)
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = ' Vis '
|
form.name.value = ' Vis '
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1, 2]
|
||||||
|
|
||||||
const result = await form.submitCreate()
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
expect(mockPost).toHaveBeenCalledWith(
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
'/categories',
|
'/categories',
|
||||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
expect(result).toEqual(CAT)
|
expect(result).toEqual(CAT)
|
||||||
@@ -203,7 +222,7 @@ describe('useCategoryForm', () => {
|
|||||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = ''
|
form.name.value = ''
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const result = await form.submitCreate()
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
@@ -215,7 +234,7 @@ describe('useCategoryForm', () => {
|
|||||||
mockPost.mockResolvedValueOnce(CAT)
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
await form.submitCreate()
|
await form.submitCreate()
|
||||||
|
|
||||||
@@ -231,7 +250,7 @@ describe('useCategoryForm', () => {
|
|||||||
})
|
})
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const result = await form.submitCreate()
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
@@ -258,7 +277,7 @@ describe('useCategoryForm', () => {
|
|||||||
})
|
})
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const result = await form.submitCreate()
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
@@ -269,24 +288,24 @@ describe('useCategoryForm', () => {
|
|||||||
expect(mockToastError).not.toHaveBeenCalled()
|
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({
|
mockPost.mockRejectedValueOnce({
|
||||||
response: {
|
response: {
|
||||||
status: 422,
|
status: 422,
|
||||||
_data: {
|
_data: {
|
||||||
'hydra:violations': [
|
'hydra:violations': [
|
||||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
await form.submitCreate()
|
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 () => {
|
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
|
||||||
@@ -295,7 +314,7 @@ describe('useCategoryForm', () => {
|
|||||||
})
|
})
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
await form.submitCreate()
|
await form.submitCreate()
|
||||||
|
|
||||||
@@ -314,7 +333,7 @@ describe('useCategoryForm', () => {
|
|||||||
)
|
)
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.name.value = 'Vis'
|
form.name.value = 'Vis'
|
||||||
form.categoryTypeId.value = 1
|
form.categoryTypeIds.value = [1]
|
||||||
|
|
||||||
const pending = form.submitCreate()
|
const pending = form.submitCreate()
|
||||||
expect(form.submitting.value).toBe(true)
|
expect(form.submitting.value).toBe(true)
|
||||||
@@ -331,28 +350,28 @@ describe('useCategoryForm', () => {
|
|||||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.loadFrom(CAT)
|
form.loadFrom(CAT)
|
||||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
form.name.value = 'Vis V2' // types inchanges
|
||||||
|
|
||||||
await form.submitUpdate(42)
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith(
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
'/categories/42',
|
'/categories/42',
|
||||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
|
||||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||||
const form = useCategoryForm()
|
const form = useCategoryForm()
|
||||||
form.loadFrom(CAT)
|
form.loadFrom(CAT)
|
||||||
form.categoryTypeId.value = 2
|
form.categoryTypeIds.value = [1, 2]
|
||||||
|
|
||||||
await form.submitUpdate(42)
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
expect(mockPatch).toHaveBeenCalledWith(
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
'/categories/42',
|
'/categories/42',
|
||||||
{ categoryType: '/api/category_types/2' },
|
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -438,7 +457,7 @@ describe('useCategoryForm', () => {
|
|||||||
form.reset()
|
form.reset()
|
||||||
|
|
||||||
expect(form.name.value).toBe('')
|
expect(form.name.value).toBe('')
|
||||||
expect(form.categoryTypeId.value).toBeNull()
|
expect(form.categoryTypeIds.value).toEqual([])
|
||||||
expect(form.errors).toEqual({})
|
expect(form.errors).toEqual({})
|
||||||
expect(form.submitting.value).toBe(false)
|
expect(form.submitting.value).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
* revalide toujours (defense en profondeur).
|
* revalide toujours (defense en profondeur).
|
||||||
*
|
*
|
||||||
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
|
* 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
|
* 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 { computed, ref } from 'vue'
|
||||||
import type { Category } from '~/modules/catalog/types/category'
|
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
|
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const categoryTypeId = ref<number | null>(null)
|
const categoryTypeIds = ref<number[]>([])
|
||||||
|
|
||||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||||
// que rien n'a change en mode consultation).
|
// que rien n'a change en mode consultation).
|
||||||
const initialName = ref('')
|
const initialName = ref('')
|
||||||
const initialCategoryTypeId = ref<number | null>(null)
|
const initialCategoryTypeIds = ref<number[]>([])
|
||||||
|
|
||||||
const submitting = ref(false)
|
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(
|
const isDirty = computed(
|
||||||
() =>
|
() =>
|
||||||
name.value !== initialName.value
|
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 {
|
function loadFrom(category: Category | null): void {
|
||||||
formErrors.clearErrors()
|
formErrors.clearErrors()
|
||||||
if (category) {
|
if (category) {
|
||||||
|
const ids = category.categoryTypes.map(t => t.id)
|
||||||
name.value = category.name
|
name.value = category.name
|
||||||
categoryTypeId.value = category.categoryType.id
|
categoryTypeIds.value = [...ids]
|
||||||
initialName.value = category.name
|
initialName.value = category.name
|
||||||
initialCategoryTypeId.value = category.categoryType.id
|
initialCategoryTypeIds.value = [...ids]
|
||||||
} else {
|
} else {
|
||||||
name.value = ''
|
name.value = ''
|
||||||
categoryTypeId.value = null
|
categoryTypeIds.value = []
|
||||||
initialName.value = ''
|
initialName.value = ''
|
||||||
initialCategoryTypeId.value = null
|
initialCategoryTypeIds.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,23 +106,23 @@ export function useCategoryForm() {
|
|||||||
formErrors.setError('name', t('admin.categories.validation.nameLength'))
|
formErrors.setError('name', t('admin.categories.validation.nameLength'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-1.05 — categoryType obligatoire.
|
// RG-1.05 — au moins un type obligatoire.
|
||||||
if (categoryTypeId.value === null) {
|
if (categoryTypeIds.value.length === 0) {
|
||||||
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
|
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
|
* Construit le payload POST a partir du state. Les `categoryTypes` sont
|
||||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
|
||||||
* Platform pour referencer une ressource liee.
|
* API Platform pour referencer une collection de ressources liees.
|
||||||
*/
|
*/
|
||||||
function buildCreatePayload(): Record<string, unknown> {
|
function buildCreatePayload(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
name: name.value.trim(),
|
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) {
|
if (name.value !== initialName.value) {
|
||||||
payload.name = name.value.trim()
|
payload.name = name.value.trim()
|
||||||
}
|
}
|
||||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) {
|
||||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`)
|
||||||
}
|
}
|
||||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||||
@@ -233,9 +244,9 @@ export function useCategoryForm() {
|
|||||||
*/
|
*/
|
||||||
function reset(): void {
|
function reset(): void {
|
||||||
name.value = ''
|
name.value = ''
|
||||||
categoryTypeId.value = null
|
categoryTypeIds.value = []
|
||||||
initialName.value = ''
|
initialName.value = ''
|
||||||
initialCategoryTypeId.value = null
|
initialCategoryTypeIds.value = []
|
||||||
formErrors.clearErrors()
|
formErrors.clearErrors()
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -243,7 +254,7 @@ export function useCategoryForm() {
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
name,
|
name,
|
||||||
categoryTypeId,
|
categoryTypeIds,
|
||||||
errors: formErrors.errors,
|
errors: formErrors.errors,
|
||||||
submitting,
|
submitting,
|
||||||
isDirty,
|
isDirty,
|
||||||
|
|||||||
@@ -3,13 +3,28 @@
|
|||||||
<PageHeader>
|
<PageHeader>
|
||||||
{{ t('admin.categories.title') }}
|
{{ t('admin.categories.title') }}
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<MalioButton
|
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme
|
||||||
v-if="canManage"
|
design que le Repertoire Clients). -->
|
||||||
:label="t('admin.categories.newCategory')"
|
<div class="flex items-center gap-12">
|
||||||
icon-name="mdi:add-bold"
|
<MalioButton
|
||||||
icon-position="left"
|
v-if="canManage"
|
||||||
@click="openCreateDrawer"
|
: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>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -47,6 +62,60 @@
|
|||||||
:loading="deleting"
|
:loading="deleting"
|
||||||
@confirm="handleDelete"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { fetchTypes } = useCategoriesAdmin()
|
const { types, fetchTypes } = useCategoriesAdmin()
|
||||||
const { submitDelete } = useCategoryForm()
|
const { submitDelete } = useCategoryForm()
|
||||||
|
|
||||||
useHead({ title: t('admin.categories.title') })
|
useHead({ title: t('admin.categories.title') })
|
||||||
@@ -74,6 +143,7 @@ const {
|
|||||||
fetch: fetchCategories,
|
fetch: fetchCategories,
|
||||||
goToPage,
|
goToPage,
|
||||||
setItemsPerPage,
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
} = usePaginatedList<Category>({ url: '/categories' })
|
} = usePaginatedList<Category>({ url: '/categories' })
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
|
|||||||
const categoryToDelete = ref<Category | null>(null)
|
const categoryToDelete = ref<Category | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
|
||||||
// on aplatit en label lisible pour l'affichage.
|
// aplatit en libelles joints par une virgule pour l'affichage.
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('admin.categories.table.name') },
|
{ 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(() =>
|
const categoryItems = computed(() =>
|
||||||
categories.value.map(cat => ({
|
categories.value.map(cat => ({
|
||||||
id: cat.id,
|
id: cat.id,
|
||||||
name: cat.name,
|
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 {
|
function getCategoryById(id: number): Category | undefined {
|
||||||
return categories.value.find(c => c.id === id)
|
return categories.value.find(c => c.id === id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
* Contrats API consommes :
|
* Contrats API consommes :
|
||||||
* - GET /api/categories → HydraCollection<Category>
|
* - GET /api/categories → HydraCollection<Category>
|
||||||
* - GET /api/categories/{id} → Category
|
* - GET /api/categories/{id} → Category
|
||||||
* - POST /api/categories → body { name, categoryType: IRI }
|
* - POST /api/categories → body { name, categoryTypes: IRI[] }
|
||||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
|
||||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||||
*
|
*
|
||||||
* Notes :
|
* Notes :
|
||||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
|
||||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
|
||||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
* proprietes de CategoryType) : tableau d'objets type en lecture.
|
||||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||||
*/
|
*/
|
||||||
@@ -43,7 +43,8 @@ export interface CategoryType {
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
categoryType: CategoryType
|
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
|
||||||
|
categoryTypes: CategoryType[]
|
||||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||||
deletedAt: string | null
|
deletedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -53,12 +54,12 @@ export interface Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
|
||||||
* IRI Hydra (ex. `/api/category_types/3`).
|
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
|
||||||
*/
|
*/
|
||||||
export interface CategoryCreateInput {
|
export interface CategoryCreateInput {
|
||||||
name: string
|
name: string
|
||||||
categoryType: string
|
categoryTypes: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
|
|||||||
*/
|
*/
|
||||||
export interface CategoryUpdateInput {
|
export interface CategoryUpdateInput {
|
||||||
name?: string
|
name?: string
|
||||||
categoryType?: string
|
categoryTypes?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ migration-migrate:
|
|||||||
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||||
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||||
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||||
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
# - `uq_category_name_active` (M0 Catalog) : unicite GLOBALE du nom parmi
|
||||||
|
# les actifs (M:N categorie<->type), tests RG-1.07.
|
||||||
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
|
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
|
||||||
# les actifs (slug du nom), pilote RG-1.03/1.29.
|
# les actifs (slug du nom), pilote RG-1.03/1.29.
|
||||||
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||||
@@ -226,7 +227,7 @@ test-db-setup:
|
|||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
$(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:sync-permissions
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
|
$(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_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_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"
|
$(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\Contract\TimestampableInterface;
|
||||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||||
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
* fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs
|
||||||
* (FK vers le referentiel statique CategoryType).
|
* `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
|
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||||
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
* 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\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||||
#[ORM\Table(name: 'category')]
|
#[ORM\Table(name: 'category')]
|
||||||
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
||||||
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
|
// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS
|
||||||
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
|
// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code
|
||||||
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
|
// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine
|
||||||
// index partiel via attribut.
|
// 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_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_created_by', columns: ['created_by'])]
|
||||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
@@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
|||||||
#[Groups(['category:read'])]
|
#[Groups(['category:read'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
/**
|
||||||
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
* Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le
|
||||||
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
* 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'])]
|
#[Groups(['category:read', 'category:write'])]
|
||||||
private ?CategoryType $categoryType = null;
|
private Collection $categoryTypes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||||
@@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
|||||||
#[Groups(['category:read'])]
|
#[Groups(['category:read'])]
|
||||||
private ?DateTimeImmutable $deletedAt = null;
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->categoryTypes = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
|||||||
return $this;
|
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;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implemente CategoryInterface : code du type rattache (ou null). Permet
|
* Implemente CategoryInterface : liste des codes de types rattaches a la
|
||||||
* aux modules tiers de filtrer/valider par type metier sans dependre de
|
* categorie. Permet aux modules tiers de filtrer/valider par type metier
|
||||||
* Catalog.
|
* (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
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
|
|||||||
@@ -23,10 +23,26 @@ interface CategoryRepositoryInterface
|
|||||||
/**
|
/**
|
||||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||||
* - $typeCode non null : ne garde que les categories dont le CategoryType
|
* - $typeCode non null : ne garde que les categories PORTANT ce code de type
|
||||||
* porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au
|
* (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select
|
||||||
* multi-select Categorie du fournisseur (M2, RG-2.10).
|
* 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).
|
* - 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
|
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
|
||||||
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
|
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
|
||||||
* UniqueConstraintViolationException remontee par Postgres (collision sur
|
* UniqueConstraintViolationException remontee par Postgres (collision sur
|
||||||
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
|
* l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les
|
||||||
* le message attendu par la spec (RG-1.07).
|
* 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 ;
|
* - 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
|
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||||
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||||
@@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface
|
|||||||
try {
|
try {
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
} 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(
|
throw new HttpException(
|
||||||
409,
|
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,
|
$e,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface
|
|||||||
$includeDeleted = $this->readIncludeDeleted($context);
|
$includeDeleted = $this->readIncludeDeleted($context);
|
||||||
|
|
||||||
if ($operation instanceof CollectionOperationInterface) {
|
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.
|
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
// 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;
|
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 = new Category();
|
||||||
$category->setName($name);
|
$category->setName($name);
|
||||||
$category->setCode($code);
|
$category->setCode($code);
|
||||||
$category->setCategoryType($type);
|
$category->addCategoryType($type);
|
||||||
$manager->persist($category);
|
$manager->persist($category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,19 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
|||||||
return [] !== $qb->getQuery()->getResult();
|
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')
|
$qb = $this->createQueryBuilder('c')
|
||||||
|
->leftJoin('c.categoryTypes', 'cte')
|
||||||
|
->addSelect('cte')
|
||||||
->orderBy('c.name', 'ASC')
|
->orderBy('c.name', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -58,16 +68,45 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
|||||||
$qb->andWhere('c.deletedAt IS NULL');
|
$qb->andWhere('c.deletedAt IS NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre `?typeCode=` : jointure sur le CategoryType pour ne garder que
|
// Filtre `?typeCode=` : la categorie doit PORTER ce code de type (RG-2.10,
|
||||||
// les categories du type demande (ex. FOURNISSEUR). La jointure reste
|
// multi-select fournisseur). Sous-requete EXISTS correlee pour ne PAS
|
||||||
// compatible avec le Paginator ORM (fetchJoinCollection) du provider.
|
// restreindre la collection eager-loadee `cte` (sinon les autres types de
|
||||||
|
// la categorie disparaitraient du JSON) et eviter les lignes dupliquees.
|
||||||
if (null !== $typeCode) {
|
if (null !== $typeCode) {
|
||||||
$qb->join('c.categoryType', 'ct')
|
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||||
->andWhere('ct.code = :typeCode')
|
->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)
|
->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;
|
return $qb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,9 +135,9 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
use TimestampableBlamableTrait;
|
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).
|
* 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).
|
* module Catalog — regle ABSOLUE n°1).
|
||||||
*/
|
*/
|
||||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
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`
|
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||||
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
||||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
|
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
|
||||||
* POST (categories ∈ supplier:write:main) comme sur PATCH.
|
* 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]
|
#[Assert\Callback]
|
||||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
foreach ($this->categories as $category) {
|
foreach ($this->categories as $category) {
|
||||||
if ($category instanceof CategoryInterface
|
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).')
|
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||||
->atPath('categories')
|
->atPath('categories')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
|||||||
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-2.10 : seules les categories de ce type sont autorisees sur une adresse
|
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
|
||||||
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCode() (pas
|
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||||
* d'import du module Catalog — regle ABSOLUE n°1).
|
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||||
*/
|
*/
|
||||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
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
|
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de
|
||||||
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
* 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]
|
#[Assert\Callback]
|
||||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
foreach ($this->categories as $category) {
|
foreach ($this->categories as $category) {
|
||||||
if ($category instanceof CategoryInterface
|
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).')
|
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||||
->atPath('categories')
|
->atPath('categories')
|
||||||
->addViolation()
|
->addViolation()
|
||||||
|
|||||||
@@ -421,11 +421,12 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
return $this->categoryCache[$name];
|
return $this->categoryCache[$name];
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par
|
// RG-2.10 : on garde la categorie des qu'elle PORTE le type FOURNISSEUR
|
||||||
// le seul `name` rattacherait une categorie homonyme d'un autre type (ex.
|
// (multi-type depuis le passage en ManyToMany). Le nom etant desormais
|
||||||
// futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une
|
// unique GLOBALEMENT parmi les actifs, le lookup par `name` renvoie au
|
||||||
// categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP
|
// plus une categorie, mais on conserve la verification du type pour
|
||||||
// (findBy ne sait pas filtrer une propriete imbriquee categoryType.code).
|
// 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([
|
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'deletedAt' => null,
|
'deletedAt' => null,
|
||||||
@@ -433,7 +434,7 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
foreach ($candidates as $candidate) {
|
||||||
if ($candidate instanceof CategoryInterface
|
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;
|
return $this->categoryCache[$name] = $candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ interface CategoryInterface
|
|||||||
public function getCode(): ?string;
|
public function getCode(): ?string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code du type de categorie rattache (CategoryType::code), ou null si la
|
* Codes des types de categorie rattaches (CategoryType::code), tableau vide
|
||||||
* categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
|
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter
|
||||||
* type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
|
* plusieurs types : un module tiers teste l'appartenance via
|
||||||
* Conserve pour l'affichage / la retrocompatibilite.
|
* `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' => [
|
'category' => [
|
||||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
'_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.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
'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.',
|
'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.',
|
||||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'category_type' => [
|
'category_type' => [
|
||||||
@@ -65,6 +64,12 @@ final class ColumnCommentsCatalog
|
|||||||
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
'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' => [
|
'permission' => [
|
||||||
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'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.
|
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
|
||||||
* Le flag $deletedAt permet de seeder directement une categorie
|
* Le flag $deletedAt permet de seeder directement une categorie
|
||||||
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
|
* 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(
|
protected function createCategory(
|
||||||
?string $name = null,
|
?string $name = null,
|
||||||
?CategoryType $type = null,
|
?CategoryType $type = null,
|
||||||
?DateTimeImmutable $deletedAt = null,
|
?DateTimeImmutable $deletedAt = null,
|
||||||
|
array $additionalTypes = [],
|
||||||
): Category {
|
): Category {
|
||||||
$em = $this->getEm();
|
$em = $this->getEm();
|
||||||
|
|
||||||
@@ -86,7 +92,10 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
|||||||
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
|
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
|
||||||
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
|
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
|
||||||
$category->setCode('TEST_'.strtoupper($suffix));
|
$category->setCode('TEST_'.strtoupper($suffix));
|
||||||
$category->setCategoryType($type);
|
$category->addCategoryType($type);
|
||||||
|
foreach ($additionalTypes as $additionalType) {
|
||||||
|
$category->addCategoryType($additionalType);
|
||||||
|
}
|
||||||
if (null !== $deletedAt) {
|
if (null !== $deletedAt) {
|
||||||
$category->setDeletedAt($deletedAt);
|
$category->setDeletedAt($deletedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
|
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
self::assertSame(201, $response->getStatusCode());
|
||||||
@@ -139,7 +139,7 @@ final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
@@ -48,7 +48,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
|
'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.
|
// Le client tente d'imposer un code : doit etre ignore.
|
||||||
'code' => 'CLIENT_FORGED',
|
'code' => 'CLIENT_FORGED',
|
||||||
],
|
],
|
||||||
@@ -65,13 +65,13 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
|||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
$client = $this->createAdminClient();
|
$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`).
|
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
|
||||||
$first = $client->request('POST', '/api/categories', [
|
$first = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
])->toArray();
|
])->toArray();
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
])->toArray();
|
])->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'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
|
'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'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'anon',
|
'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'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
|
'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'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
|
'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'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
self::assertSame(201, $response->getStatusCode());
|
||||||
@@ -140,7 +140,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
self::assertSame(201, $response->getStatusCode());
|
||||||
@@ -220,7 +220,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
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.',
|
'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) {
|
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);
|
self::assertArrayHasKey('member', $data);
|
||||||
|
|
||||||
foreach ($data['member'] as $member) {
|
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;
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
|
* Tests RG-1.07 : unicite case-insensitive du nom GLOBALEMENT (LOWER(name))
|
||||||
* parmi les categories non soft-deleted. L'index Postgres partiel
|
* parmi les categories non soft-deleted. Depuis le passage en ManyToMany,
|
||||||
* `uq_category_name_type_active` est traduit en 409 Conflict par le
|
* l'unicite n'est plus liee au type. L'index Postgres partiel
|
||||||
* CategoryProcessor.
|
* `uq_category_name_active` est traduit en 409 Conflict par le CategoryProcessor.
|
||||||
*
|
*
|
||||||
* Cas couverts :
|
* Cas couverts :
|
||||||
* - doublon strict (meme name + meme type) → 409 ;
|
* - doublon strict (meme name) → 409 ;
|
||||||
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
|
* - doublon case-insensitive (Vis / VIS) → 409 ;
|
||||||
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
|
* - meme name avec des types differents → 409 (unicite GLOBALE) ;
|
||||||
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
|
* - recreation apres soft delete → 201 (l'index partiel libere le nom).
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||||
{
|
{
|
||||||
public function testDuplicateNameSameTypeReturns409(): void
|
public function testDuplicateNameReturns409(): void
|
||||||
{
|
{
|
||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
@@ -29,29 +29,29 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
// 2eme POST : meme name + meme type → doublon strict.
|
// 2eme POST : meme name → doublon (unicite globale).
|
||||||
$response = $client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(409, $response->getStatusCode());
|
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);
|
$payload = $response->toArray(false);
|
||||||
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
'existe déjà pour ce type',
|
'existe déjà',
|
||||||
$description,
|
$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', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
@@ -74,17 +74,17 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
|||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
// Meme prefix mais variation de casse → meme LOWER → collision.
|
// Meme prefix mais variation de casse → meme LOWER → collision.
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(409, $response->getStatusCode());
|
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.
|
// RG-1.07 (reformulee) : l'unicite du nom est desormais GLOBALE — le
|
||||||
// Le meme nom doit etre acceptable sur deux types differents.
|
// meme nom sur deux types differents est un doublon.
|
||||||
$type1 = $this->createCategoryType();
|
$type1 = $this->createCategoryType();
|
||||||
$type2 = $this->createCategoryType();
|
$type2 = $this->createCategoryType();
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
@@ -92,27 +92,27 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||||
'categoryType' => '/api/category_types/'.$type1->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type1->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
$client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||||
'categoryType' => '/api/category_types/'.$type2->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type2->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertSame(409, $response->getStatusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRecreateAfterSoftDeleteAllowed(): void
|
public function testRecreateAfterSoftDeleteAllowed(): void
|
||||||
{
|
{
|
||||||
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
|
// 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
|
// Apres un soft delete, le nom est libere et un nouveau POST identique
|
||||||
// nouveau POST identique doit reussir.
|
// doit reussir.
|
||||||
$type = $this->createCategoryType();
|
$type = $this->createCategoryType();
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
|||||||
$response = $client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertSame(201, $response->getStatusCode());
|
self::assertSame(201, $response->getStatusCode());
|
||||||
@@ -131,12 +131,12 @@ final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('DELETE', '/api/categories/'.$created['id']);
|
$client->request('DELETE', '/api/categories/'.$created['id']);
|
||||||
self::assertResponseStatusCodeSame(204);
|
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', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use App\Module\Catalog\Domain\Entity\Category;
|
|||||||
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
||||||
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
||||||
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
||||||
* - RG-1.05 : `categoryType` obligatoire ;
|
* - RG-1.05 : `categoryTypes` — au moins un type (Count min 1) ;
|
||||||
* - RG-1.06 : `categoryType` doit pointer un type existant.
|
* - RG-1.06 : chaque IRI de `categoryTypes` doit pointer un type existant.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -27,7 +27,7 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
// name absent
|
// name absent
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -42,8 +42,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => '',
|
'name' => '',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => ' ',
|
'name' => ' ',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -79,8 +79,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => $payloadName,
|
'name' => $payloadName,
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -103,8 +103,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => 'A',
|
'name' => 'A',
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -118,8 +118,8 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => str_repeat('a', 121),
|
'name' => str_repeat('a', 121),
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -140,71 +140,74 @@ final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
|||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
'categoryTypes' => ['/api/category_types/'.$type->getId()],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
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 = $this->createAdminClient();
|
||||||
$client->request('POST', '/api/categories', [
|
$client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
||||||
// categoryType absent
|
// categoryTypes absent -> collection vide -> Count(min:1) viole.
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCategoryTypeNullIsRejected(): void
|
public function testCategoryTypesEmptyReturns422(): void
|
||||||
{
|
{
|
||||||
// `categoryType: null` echoue a la deserialization IRI (API Platform
|
// Tableau vide explicite : Assert\Count(min: 1) doit declencher 422 avec
|
||||||
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
|
// une violation sur le propertyPath `categoryTypes` (consommable inline).
|
||||||
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
|
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
|
'name' => self::TEST_CATEGORY_PREFIX.'empty_types',
|
||||||
'categoryType' => null,
|
'categoryTypes' => [],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
self::assertSame(422, $response->getStatusCode());
|
||||||
|
$payload = $response->toArray(false);
|
||||||
|
$violations = $payload['violations'] ?? $payload['hydra:violations'] ?? [];
|
||||||
|
$paths = array_column($violations, 'propertyPath');
|
||||||
self::assertContains(
|
self::assertContains(
|
||||||
$response->getStatusCode(),
|
'categoryTypes',
|
||||||
[400, 422],
|
$paths,
|
||||||
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
|
'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
|
public function testCategoryTypeMustExistReturns4xx(): void
|
||||||
{
|
{
|
||||||
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
||||||
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
|
// (resolution IRI echouee) ou 422 (validation declenchee). La spec § 4.3
|
||||||
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
|
// accepte les deux : on assert le contrat "ne passe pas".
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('POST', '/api/categories', [
|
$response = $client->request('POST', '/api/categories', [
|
||||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
'json' => [
|
'json' => [
|
||||||
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||||
'categoryType' => '/api/category_types/9999999',
|
'categoryTypes' => ['/api/category_types/9999999'],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertContains(
|
self::assertContains(
|
||||||
$response->getStatusCode(),
|
$response->getStatusCode(),
|
||||||
[400, 404, 422],
|
[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 = new Category();
|
||||||
$category->setName($name);
|
$category->setName($name);
|
||||||
$category->setCode($effectiveCode);
|
$category->setCode($effectiveCode);
|
||||||
$category->setCategoryType($this->clientCategoryType());
|
$category->addCategoryType($this->clientCategoryType());
|
||||||
$em->persist($category);
|
$em->persist($category);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
|||||||
$category = new Category();
|
$category = new Category();
|
||||||
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
|
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
|
||||||
$category->setCode($code);
|
$category->setCode($code);
|
||||||
$category->setCategoryType($this->supplierCategoryType());
|
$category->addCategoryType($this->supplierCategoryType());
|
||||||
$em->persist($category);
|
$em->persist($category);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ final class SupplierValidationTest extends TestCase
|
|||||||
self::assertContains('categories', $this->violationPaths($supplier));
|
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 ===
|
// === RG-2.07 : Virement impose une banque ===
|
||||||
|
|
||||||
public function testVirementWithoutBankIsRejectedOnBankPath(): void
|
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
|
* Double minimal de CategoryInterface (pas d'acces base) PORTANT les codes de
|
||||||
* type de categorie voulu — seul element regarde par validateCategoryType.
|
* 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 {
|
return new class(array_values($typeCodes)) implements CategoryInterface {
|
||||||
public function __construct(private readonly string $typeCode) {}
|
/** @param list<string> $typeCodes */
|
||||||
|
public function __construct(private readonly array $typeCodes) {}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
@@ -154,9 +169,10 @@ final class SupplierValidationTest extends TestCase
|
|||||||
return 'TEST';
|
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