diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml index 56062ab..c981a71 100644 --- a/.gitea/workflows/pull-request.yml +++ b/.gitea/workflows/pull-request.yml @@ -75,7 +75,7 @@ jobs: - name: Bootstrap test database # Aligne sur la cible `test-db-setup` du makefile : apres # `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 # deleted_at IS NULL) et `schema:update` les considere comme # 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 doctrine:fixtures:load --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 run: php -d memory_limit=512M vendor/bin/phpunit diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 39a87f1..3f20c17 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -420,17 +420,24 @@ "noCategories": "Aucune catégorie pour l'instant.", "table": { "name": "Nom", - "type": "Type" + "types": "Types" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "types": "Types de catégorie", + "apply": "Voir les résultats", + "reset": "Réinitialiser" }, "form": { "name": "Nom", - "type": "Type de catégorie", - "typePlaceholder": "Sélectionner un type" + "types": "Types de catégorie", + "typesPlaceholder": "Sélectionner un ou plusieurs types" }, "validation": { "nameRequired": "Le nom est obligatoire.", "nameLength": "Le nom doit faire entre 2 et 120 caractères.", - "typeRequired": "Le type de catégorie est obligatoire." + "typesRequired": "Sélectionnez au moins un type de catégorie." }, "delete": { "title": "Supprimer la catégorie", @@ -440,7 +447,7 @@ "created": "Catégorie créée avec succès", "updated": "Catégorie mise à jour avec succès", "deleted": "Catégorie supprimée avec succès", - "duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.", + "duplicate": "Une catégorie nommée « {name} » existe déjà.", "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." } } diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 9ada8aa..1a77541 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -24,16 +24,18 @@ required /> - - + diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts index 5a14387..c46cffe 100644 --- a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } const CAT: Category = { id: 42, name: 'Vis', - categoryType: TYPE_VENTE, + categoryTypes: [TYPE_VENTE], deletedAt: null, createdAt: '2026-01-01T10:00:00+00:00', updatedAt: '2026-01-01T10:00:00+00:00', @@ -58,25 +58,25 @@ describe('useCategoryForm', () => { }) describe('loadFrom', () => { - it('pre-remplit le formulaire depuis une categorie existante', () => { + it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => { const form = useCategoryForm() - form.loadFrom(CAT) + form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) expect(form.name.value).toBe('Vis') - expect(form.categoryTypeId.value).toBe(1) + expect(form.categoryTypeIds.value).toEqual([1, 2]) expect(form.errors).toEqual({}) }) it('vide le formulaire en mode creation (null)', () => { const form = useCategoryForm() form.name.value = 'old' - form.categoryTypeId.value = 99 + form.categoryTypeIds.value = [99] form.loadFrom(null) expect(form.name.value).toBe('') - expect(form.categoryTypeId.value).toBeNull() + expect(form.categoryTypeIds.value).toEqual([]) }) it('reinitialise le snapshot initial → isDirty=false juste apres', () => { @@ -98,13 +98,32 @@ describe('useCategoryForm', () => { expect(form.isDirty.value).toBe(true) }) + + it('passe a true quand on ajoute un type (selection multi)', () => { + const form = useCategoryForm() + form.loadFrom(CAT) + expect(form.isDirty.value).toBe(false) + + form.categoryTypeIds.value = [1, 2] + + expect(form.isDirty.value).toBe(true) + }) + + it('reste false si la selection est identique dans un autre ordre', () => { + const form = useCategoryForm() + form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) + + form.categoryTypeIds.value = [2, 1] + + expect(form.isDirty.value).toBe(false) + }) }) describe('validate', () => { it('signale une erreur si name est vide (RG-1.02)', () => { const form = useCategoryForm() form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -115,7 +134,7 @@ describe('useCategoryForm', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => { const form = useCategoryForm() form.name.value = ' ' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -126,7 +145,7 @@ describe('useCategoryForm', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { const form = useCategoryForm() form.name.value = 'A' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -137,7 +156,7 @@ describe('useCategoryForm', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { const form = useCategoryForm() form.name.value = 'A'.repeat(121) - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const ok = form.validate() @@ -145,21 +164,21 @@ describe('useCategoryForm', () => { expect(form.errors.name).toBe('admin.categories.validation.nameLength') }) - it('signale erreur si categoryTypeId est null (RG-1.05)', () => { + it('signale erreur si aucun type selectionne (RG-1.05)', () => { const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = null + form.categoryTypeIds.value = [] const ok = form.validate() expect(ok).toBe(false) - expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired') + expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired') }) - it('passe quand name et categoryType sont valides', () => { + it('passe quand name et au moins un type sont valides', () => { const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1, 2] const ok = form.validate() @@ -171,7 +190,7 @@ describe('useCategoryForm', () => { const form = useCategoryForm() // Erreur prealable : une validation en echec peuple errors.name. form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] form.validate() expect(form.errors.name).toBeTruthy() @@ -184,17 +203,17 @@ describe('useCategoryForm', () => { }) describe('submitCreate', () => { - it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => { + it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => { mockPost.mockResolvedValueOnce(CAT) const form = useCategoryForm() form.name.value = ' Vis ' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1, 2] const result = await form.submitCreate() expect(mockPost).toHaveBeenCalledWith( '/categories', - { name: 'Vis', categoryType: '/api/category_types/1' }, + { name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { toast: false }, ) expect(result).toEqual(CAT) @@ -203,7 +222,7 @@ describe('useCategoryForm', () => { it('ne declenche aucun appel API si la validation client echoue', async () => { const form = useCategoryForm() form.name.value = '' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -215,7 +234,7 @@ describe('useCategoryForm', () => { mockPost.mockResolvedValueOnce(CAT) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() @@ -231,7 +250,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -258,7 +277,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const result = await form.submitCreate() @@ -269,24 +288,24 @@ describe('useCategoryForm', () => { expect(mockToastError).not.toHaveBeenCalled() }) - it('mappe aussi hydra:violations (negociation de format alternative)', async () => { + it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { 'hydra:violations': [ - { propertyPath: 'categoryType', message: 'Type invalide.' }, + { propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' }, ], }, }, }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() - expect(form.errors.categoryType).toBe('Type invalide.') + expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.') }) it('fallback en toast generique si le status n est ni 409 ni 422', async () => { @@ -295,7 +314,7 @@ describe('useCategoryForm', () => { }) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] await form.submitCreate() @@ -314,7 +333,7 @@ describe('useCategoryForm', () => { ) const form = useCategoryForm() form.name.value = 'Vis' - form.categoryTypeId.value = 1 + form.categoryTypeIds.value = [1] const pending = form.submitCreate() expect(form.submitting.value).toBe(true) @@ -331,28 +350,28 @@ describe('useCategoryForm', () => { mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) const form = useCategoryForm() form.loadFrom(CAT) - form.name.value = 'Vis V2' // categoryTypeId inchange + form.name.value = 'Vis V2' // types inchanges await form.submitUpdate(42) expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { name: 'Vis V2' }, // pas de categoryType car non modifie + { name: 'Vis V2' }, // pas de categoryTypes car non modifies { toast: false }, ) }) - it('envoie categoryType en IRI quand seul le type a change', async () => { - mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT }) + it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => { + mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] }) const form = useCategoryForm() form.loadFrom(CAT) - form.categoryTypeId.value = 2 + form.categoryTypeIds.value = [1, 2] await form.submitUpdate(42) expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { categoryType: '/api/category_types/2' }, + { categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { toast: false }, ) }) @@ -438,7 +457,7 @@ describe('useCategoryForm', () => { form.reset() expect(form.name.value).toBe('') - expect(form.categoryTypeId.value).toBeNull() + expect(form.categoryTypeIds.value).toEqual([]) expect(form.errors).toEqual({}) expect(form.submitting.value).toBe(false) }) diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts index 689b9f3..2fadf5e 100644 --- a/frontend/modules/catalog/composables/useCategoryForm.ts +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -13,9 +13,10 @@ * revalide toujours (defense en profondeur). * * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les - * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ; + * violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ; * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon - * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast. + * de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur + * `name` + toast. */ import { computed, ref } from 'vue' import type { Category } from '~/modules/catalog/types/category' @@ -42,20 +43,29 @@ export function useCategoryForm() { // State local du formulaire — pas singleton, chaque appel a useCategoryForm // cree son propre state (cohérent avec le pattern « un drawer = un form »). const name = ref('') - const categoryTypeId = ref(null) + const categoryTypeIds = ref([]) // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le // pattern view → edit du drawer (le bouton Enregistrer reste masque tant // que rien n'a change en mode consultation). const initialName = ref('') - const initialCategoryTypeId = ref(null) + const initialCategoryTypeIds = ref([]) const submitting = ref(false) + // Compare deux listes d'ids sans tenir compte de l'ordre (la selection + // multi-types n'est pas ordonnee). + function sameIds(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false + const sortedA = [...a].sort((x, y) => x - y) + const sortedB = [...b].sort((x, y) => x - y) + return sortedA.every((v, i) => v === sortedB[i]) + } + const isDirty = computed( () => name.value !== initialName.value - || categoryTypeId.value !== initialCategoryTypeId.value, + || !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value), ) /** @@ -66,15 +76,16 @@ export function useCategoryForm() { function loadFrom(category: Category | null): void { formErrors.clearErrors() if (category) { + const ids = category.categoryTypes.map(t => t.id) name.value = category.name - categoryTypeId.value = category.categoryType.id + categoryTypeIds.value = [...ids] initialName.value = category.name - initialCategoryTypeId.value = category.categoryType.id + initialCategoryTypeIds.value = [...ids] } else { name.value = '' - categoryTypeId.value = null + categoryTypeIds.value = [] initialName.value = '' - initialCategoryTypeId.value = null + initialCategoryTypeIds.value = [] } } @@ -95,23 +106,23 @@ export function useCategoryForm() { formErrors.setError('name', t('admin.categories.validation.nameLength')) } - // RG-1.05 — categoryType obligatoire. - if (categoryTypeId.value === null) { - formErrors.setError('categoryType', t('admin.categories.validation.typeRequired')) + // RG-1.05 — au moins un type obligatoire. + if (categoryTypeIds.value.length === 0) { + formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired')) } - return !formErrors.errors.name && !formErrors.errors.categoryType + return !formErrors.errors.name && !formErrors.errors.categoryTypes } /** - * Construit le payload POST a partir du state. Le `categoryType` est - * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API - * Platform pour referencer une ressource liee. + * Construit le payload POST a partir du state. Les `categoryTypes` sont + * envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention + * API Platform pour referencer une collection de ressources liees. */ function buildCreatePayload(): Record { return { name: name.value.trim(), - categoryType: `/api/category_types/${categoryTypeId.value}`, + categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), } } @@ -174,8 +185,8 @@ export function useCategoryForm() { if (name.value !== initialName.value) { payload.name = name.value.trim() } - if (categoryTypeId.value !== initialCategoryTypeId.value) { - payload.categoryType = `/api/category_types/${categoryTypeId.value}` + if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) { + payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`) } // Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement // empeche par le drawer (bouton Enregistrer masque si !isDirty) mais @@ -233,9 +244,9 @@ export function useCategoryForm() { */ function reset(): void { name.value = '' - categoryTypeId.value = null + categoryTypeIds.value = [] initialName.value = '' - initialCategoryTypeId.value = null + initialCategoryTypeIds.value = [] formErrors.clearErrors() submitting.value = false } @@ -243,7 +254,7 @@ export function useCategoryForm() { return { // State name, - categoryTypeId, + categoryTypeIds, errors: formErrors.errors, submitting, isDirty, diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue index 6d4104e..784b16e 100644 --- a/frontend/modules/catalog/pages/admin/categories.vue +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -3,13 +3,28 @@ {{ t('admin.categories.title') }} @@ -47,6 +62,60 @@ :loading="deleting" @confirm="handleDelete" /> + + + + + + + + + + + + + +
+ +
+
+
+ + +
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category' const { t } = useI18n() const { can } = usePermissions() -const { fetchTypes } = useCategoriesAdmin() +const { types, fetchTypes } = useCategoriesAdmin() const { submitDelete } = useCategoryForm() useHead({ title: t('admin.categories.title') }) @@ -74,6 +143,7 @@ const { fetch: fetchCategories, goToPage, setItemsPerPage, + setFilters, } = usePaginatedList({ url: '/categories' }) const drawerOpen = ref(false) @@ -82,21 +152,96 @@ const deleteModalOpen = ref(false) const categoryToDelete = ref(null) const deleting = ref(false) -// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) — -// on aplatit en label lisible pour l'affichage. +// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on +// aplatit en libelles joints par une virgule pour l'affichage. const columns = [ { key: 'name', label: t('admin.categories.table.name') }, - { key: 'typeLabel', label: t('admin.categories.table.type') }, + { key: 'typesLabel', label: t('admin.categories.table.types') }, ] const categoryItems = computed(() => categories.value.map(cat => ({ id: cat.id, name: cat.name, - typeLabel: cat.categoryType?.label ?? '', + typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '), })), ) +// ── Filtres (drawer) ──────────────────────────────────────────────────────── +// Deux niveaux d'etat (pattern Repertoire Clients) : +// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au +// clic « Appliquer » / « Réinitialiser ». +// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation. +const filterDrawerOpen = ref(false) + +const draftSearch = ref('') +const draftTypeIds = ref([]) + +const appliedSearch = ref('') +const appliedTypeIds = ref([]) + +// 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 { + const payload: Record = {} + if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim() + if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String) + return payload +} + +// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en +// page 1 via usePaginatedList) et ferme le drawer. +function applyFilters(): void { + appliedSearch.value = draftSearch.value.trim() + appliedTypeIds.value = [...draftTypeIds.value] + + setFilters(buildFilterPayload(), { replace: true }) + filterDrawerOpen.value = false +} + +// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete. +// Le drawer reste ouvert pour montrer le formulaire vide. +function resetFilters(): void { + draftSearch.value = '' + draftTypeIds.value = [] + appliedSearch.value = '' + appliedTypeIds.value = [] + + setFilters({}, { replace: true }) +} + function getCategoryById(id: number): Category | undefined { return categories.value.find(c => c.id === id) } diff --git a/frontend/modules/catalog/types/category.ts b/frontend/modules/catalog/types/category.ts index acb154d..4cc9947 100644 --- a/frontend/modules/catalog/types/category.ts +++ b/frontend/modules/catalog/types/category.ts @@ -4,15 +4,15 @@ * Contrats API consommes : * - GET /api/categories → HydraCollection * - GET /api/categories/{id} → Category - * - POST /api/categories → body { name, categoryType: IRI } - * - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI } + * - POST /api/categories → body { name, categoryTypes: IRI[] } + * - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] } * - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor) * - GET /api/category_types → HydraCollection * * Notes : - * - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3"). - * - `categoryType` est embarque (groupe Serializer `category:read` sur les - * proprietes de CategoryType, cf. spec-back § 3.4). + * - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]). + * - `categoryTypes` est embarque (groupe Serializer `category:read` sur les + * proprietes de CategoryType) : tableau d'objets type en lecture. * - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP, * ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null. */ @@ -43,7 +43,8 @@ export interface CategoryType { export interface Category { id: number name: string - categoryType: CategoryType + /** Types de la categorie (>= 1, ManyToMany embarque en lecture). */ + categoryTypes: CategoryType[] /** Soft delete : null = active, valeur = supprimee logiquement le {date}. */ deletedAt: string | null createdAt: string @@ -53,12 +54,12 @@ export interface Category { } /** - * Payload accepte en POST /api/categories. `categoryType` est envoye en - * IRI Hydra (ex. `/api/category_types/3`). + * Payload accepte en POST /api/categories. `categoryTypes` est un tableau + * d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`). */ export interface CategoryCreateInput { name: string - categoryType: string + categoryTypes: string[] } /** @@ -67,5 +68,5 @@ export interface CategoryCreateInput { */ export interface CategoryUpdateInput { name?: string - categoryType?: string + categoryTypes?: string[] } diff --git a/makefile b/makefile index 0367e92..b749e4b 100644 --- a/makefile +++ b/makefile @@ -207,7 +207,8 @@ migration-migrate: # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc # 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 # les actifs (slug du nom), pilote RG-1.03/1.29. # - `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 app:sync-permissions $(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac - $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" diff --git a/migrations/Version20260608120000.php b/migrations/Version20260608120000.php new file mode 100644 index 0000000..d93db8f --- /dev/null +++ b/migrations/Version20260608120000.php @@ -0,0 +1,149 @@ + 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, + )); + } +} diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index be04b81..03dc0d7 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -19,14 +19,18 @@ use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; /** * Categorie : referentiel metier classifiant les futurs tiers (clients, - * fournisseurs, prestataires). Porte un `name` libre et un `categoryType` - * (FK vers le referentiel statique CategoryType). + * fournisseurs, prestataires). Porte un `name` libre et un ou plusieurs + * `categoryTypes` (ManyToMany vers le referentiel statique CategoryType, + * table de jonction `category_category_type`). Une categorie peut appartenir + * a plusieurs types simultanement (>= 1 obligatoire, RG-1.05). * * - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par * defaut les categories supprimees (cf. CategoryProvider, ticket 0.3). @@ -81,12 +85,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] #[ORM\Table(name: 'category')] // Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index -// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id -// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL) -// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un -// index partiel via attribut. +// uniques partiels `uq_category_name_active` (LOWER(name) WHERE deleted_at IS +// NULL — unicite GLOBALE du nom parmi les actifs) et `uq_category_code` (code +// WHERE deleted_at IS NULL) restent possedes par la seule migration : Doctrine +// ORM ne sait pas exprimer un index partiel via attribut. #[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])] -#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])] #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])] #[Auditable] @@ -126,11 +129,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt #[Groups(['category:read'])] private ?string $code = null; - #[ORM\ManyToOne(targetEntity: CategoryType::class)] - #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] - #[Assert\NotNull(message: 'Type de catégorie obligatoire.')] + /** + * Types de la categorie (>= 1 obligatoire, RG-1.05). ManyToMany vers le + * referentiel statique CategoryType via la jonction `category_category_type`. + * Cote inverse (category_type) en ON DELETE RESTRICT : un type ne peut etre + * supprime tant qu'il reste reference par une categorie. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: CategoryType::class)] + #[ORM\JoinTable(name: 'category_category_type')] + #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'category_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de catégorie.')] #[Groups(['category:read', 'category:write'])] - private ?CategoryType $categoryType = null; + private Collection $categoryTypes; /** * Soft delete : null = active, valeur = supprimee logiquement le {date}. @@ -141,6 +154,11 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt #[Groups(['category:read'])] private ?DateTimeImmutable $deletedAt = null; + public function __construct() + { + $this->categoryTypes = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -173,26 +191,42 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt return $this; } - public function getCategoryType(): ?CategoryType + /** + * @return Collection + */ + public function getCategoryTypes(): Collection { - return $this->categoryType; + return $this->categoryTypes; } - public function setCategoryType(?CategoryType $categoryType): static + public function addCategoryType(CategoryType $categoryType): static { - $this->categoryType = $categoryType; + if (!$this->categoryTypes->contains($categoryType)) { + $this->categoryTypes->add($categoryType); + } + + return $this; + } + + public function removeCategoryType(CategoryType $categoryType): static + { + $this->categoryTypes->removeElement($categoryType); return $this; } /** - * Implemente CategoryInterface : code du type rattache (ou null). Permet - * aux modules tiers de filtrer/valider par type metier sans dependre de - * Catalog. + * Implemente CategoryInterface : liste des codes de types rattaches a la + * categorie. Permet aux modules tiers de filtrer/valider par type metier + * (ex: RG-2.10 « contient FOURNISSEUR ») sans dependre de Catalog. + * + * @return list */ - 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 diff --git a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php index 60f7eb8..17c1ba8 100644 --- a/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/CategoryRepositoryInterface.php @@ -23,10 +23,26 @@ interface CategoryRepositoryInterface /** * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) - * - $typeCode non null : ne garde que les categories dont le CategoryType - * porte ce code (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au - * multi-select Categorie du fournisseur (M2, RG-2.10). + * - $typeCode non null : ne garde que les categories PORTANT ce code de type + * (filtre `?typeCode=`, ex. FOURNISSEUR / CLIENT). Sert au multi-select + * Categorie du fournisseur (M2, RG-2.10). + * - $nameSearch non null : recherche partielle case-insensitive sur le nom + * (filtre `?name=` de la liste admin). + * - $typeIds non vide : ne garde que les categories portant AU MOINS UN des + * types (OR, filtre `?typeId[]=` de la liste admin). * - Tri : name ASC (RG-1.10). + * + * Les categories etant en ManyToMany avec leurs types, la collection + * `categoryTypes` est eager-loadee (addSelect) pour eviter un N+1 a la + * serialisation, et `distinct` est applique des qu'un filtre type joint la + * table de jonction (evite les lignes dupliquees). + * + * @param list $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; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php index c97ef7c..8f34334 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/CategoryProcessor.php @@ -22,8 +22,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException; * via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine * ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute * UniqueConstraintViolationException remontee par Postgres (collision sur - * l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec - * le message attendu par la spec (RG-1.07). + * l'index partiel uq_category_name_active — unicite GLOBALE du nom parmi les + * actifs) est traduite en HTTP 409 avec le message attendu par la spec (RG-1.07). * - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ; * on pose deletedAt = now() puis on delegue au persist_processor pour que * le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette @@ -78,10 +78,12 @@ final class CategoryProcessor implements ProcessorInterface try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { - // RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted. + // RG-1.07 : doublon de nom GLOBAL (LOWER(name)) parmi les non-soft-deleted + // (uq_category_name_active). L'unicite n'est plus liee au type depuis le + // passage en ManyToMany. throw new HttpException( 409, - sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''), + sprintf('Une catégorie nommée "%s" existe déjà.', $data->getName() ?? ''), $e, ); } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php index 25fc1b5..a0cc167 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/CategoryProvider.php @@ -40,7 +40,12 @@ final class CategoryProvider implements ProviderInterface $includeDeleted = $this->readIncludeDeleted($context); if ($operation instanceof CollectionOperationInterface) { - $qb = $this->repository->createListQueryBuilder($includeDeleted, $this->readTypeCode($context)); + $qb = $this->repository->createListQueryBuilder( + $includeDeleted, + $this->readTypeCode($context), + $this->readNameSearch($context), + $this->readTypeIds($context), + ); // Echappatoire ?pagination=false : retourne la collection complete sans Paginator. // Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un