From a340d8139acbb0792ac9d2d6d539aed84fc65d44 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 12 Jun 2026 08:45:38 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(commercial)=20:=20am=C3=A9lioration=20?= =?UTF-8?q?et=20validation=20stricte=20des=20champs=20date=20(ERP-148)=20(?= =?UTF-8?q?#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur). ## Changements - **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`). - **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**. - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté. - **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`. - Réorganisation des utils de formulaire sous `utils/forms/`. ## Tests - Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`). - Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs). - Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué). ## Note Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/92 Co-authored-by: tristan Co-committed-by: tristan --- .claude/rules/frontend.md | 1 + frontend/i18n/locales/fr.json | 5 +- .../components/ClientAddressBlock.vue | 2 +- .../commercial/composables/useClient.ts | 2 +- .../commercial/composables/useSupplier.ts | 2 +- .../commercial/pages/clients/[id]/edit.vue | 7 +- .../commercial/pages/clients/[id]/index.vue | 4 +- .../modules/commercial/pages/clients/new.vue | 10 ++- .../commercial/pages/suppliers/[id]/edit.vue | 7 +- .../commercial/pages/suppliers/[id]/index.vue | 4 +- .../commercial/pages/suppliers/new.vue | 7 +- .../__tests__/clientConsultation.spec.ts | 0 .../{ => forms}/__tests__/clientEdit.spec.ts | 11 +++ .../__tests__/clientFormRules.spec.ts | 0 .../__tests__/supplierConsultation.spec.ts | 0 .../__tests__/supplierEdit.spec.ts | 13 +++- .../__tests__/supplierFormRules.spec.ts | 0 .../utils/{ => forms}/clientConsultation.ts | 0 .../utils/{ => forms}/clientEdit.ts | 17 ++++- .../utils/{ => forms}/clientFormRules.ts | 0 .../utils/{ => forms}/supplierConsultation.ts | 0 .../utils/{ => forms}/supplierEdit.ts | 17 ++++- .../utils/{ => forms}/supplierFormRules.ts | 0 frontend/package-lock.json | 8 +-- frontend/package.json | 2 +- .../__tests__/useFormErrors.test.ts | 16 +++++ frontend/shared/composables/useFormErrors.ts | 17 +++-- frontend/shared/utils/__tests__/api.test.ts | 29 +++++++- frontend/shared/utils/api.ts | 46 +++++++++++- .../Commercial/Domain/Entity/Client.php | 19 +++++ .../Commercial/Domain/Entity/Supplier.php | 17 +++++ .../Api/ClientFoundedAtFormatTest.php | 71 +++++++++++++++++++ .../Api/SupplierFoundedAtFormatTest.php | 71 +++++++++++++++++++ 33 files changed, 364 insertions(+), 41 deletions(-) rename frontend/modules/commercial/utils/{ => forms}/__tests__/clientConsultation.spec.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/__tests__/clientEdit.spec.ts (95%) rename frontend/modules/commercial/utils/{ => forms}/__tests__/clientFormRules.spec.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/__tests__/supplierConsultation.spec.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/__tests__/supplierEdit.spec.ts (93%) rename frontend/modules/commercial/utils/{ => forms}/__tests__/supplierFormRules.spec.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/clientConsultation.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/clientEdit.ts (93%) rename frontend/modules/commercial/utils/{ => forms}/clientFormRules.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/supplierConsultation.ts (100%) rename frontend/modules/commercial/utils/{ => forms}/supplierEdit.ts (92%) rename frontend/modules/commercial/utils/{ => forms}/supplierFormRules.ts (100%) create mode 100644 tests/Module/Commercial/Api/ClientFoundedAtFormatTest.php create mode 100644 tests/Module/Commercial/Api/SupplierFoundedAtFormatTest.php diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index f5714c3..0580119 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -79,6 +79,7 @@ Regles : - **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin). - **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07). - **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`. +- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`). **Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`. diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d3ea681..cd6a8ad 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -386,7 +386,10 @@ }, "title": "Erreur", "generic": "Une erreur est survenue.", - "unknown": "Erreur inconnue." + "unknown": "Erreur inconnue.", + "validation": { + "invalidDate": "Date invalide" + } }, "sites": { "selector": { diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 3533a7d..ff2c574 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -187,7 +187,7 @@ import { addressTypeFromFlags, isBillingEmailRequired, type AddressType, -} from '~/modules/commercial/utils/clientFormRules' +} from '~/modules/commercial/utils/forms/clientFormRules' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials' import type { AddressFormDraft } from '~/modules/commercial/types/clientForm' diff --git a/frontend/modules/commercial/composables/useClient.ts b/frontend/modules/commercial/composables/useClient.ts index eff43cc..a91396c 100644 --- a/frontend/modules/commercial/composables/useClient.ts +++ b/frontend/modules/commercial/composables/useClient.ts @@ -1,5 +1,5 @@ import { ref } from 'vue' -import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation' +import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation' /** * Chargement et actions d'archivage d'un client unique (ecran « Consultation diff --git a/frontend/modules/commercial/composables/useSupplier.ts b/frontend/modules/commercial/composables/useSupplier.ts index 47d05a1..c77f25e 100644 --- a/frontend/modules/commercial/composables/useSupplier.ts +++ b/frontend/modules/commercial/composables/useSupplier.ts @@ -1,5 +1,5 @@ import { ref } from 'vue' -import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' +import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation' /** * Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 86604b4..75c477a 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -116,6 +116,7 @@ :readonly="businessReadonly" :editable="true" :error="informationErrors.errors.foundedAt" + @update:raw-value="(v: string) => information.foundedAtRaw = v" /> import { computed, onMounted, ref } from 'vue' import { useClient } from '~/modules/commercial/composables/useClient' -import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules' +import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules' import { readHistoryTab } from '~/shared/utils/historyTab' import { canEditClient, @@ -297,7 +297,7 @@ import { showRestoreAction, type ClientDetail, type SelectOption, -} from '~/modules/commercial/utils/clientConsultation' +} from '~/modules/commercial/utils/forms/clientConsultation' import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' // Masque d'affichage (purement visuel, la donnee reste celle du serveur). diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 7fde08f..a3e9617 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -111,6 +111,7 @@ :readonly="isValidated('information')" :editable="true" :error="informationErrors.errors.foundedAt" + @update:raw-value="(v: string) => information.foundedAtRaw = v" /> { await api.patch(`/clients/${clientId.value}`, { description: information.description || null, competitors: information.competitors || null, - foundedAt: information.foundedAt || null, + // Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw). + foundedAt: information.foundedAtRaw || information.foundedAt || null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null, revenueAmount: information.revenueAmount || null, profitAmount: information.profitAmount || null, diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue index 39ab978..8695ca2 100644 --- a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -77,6 +77,7 @@ :readonly="businessReadonly" :editable="true" :error="informationErrors.errors.foundedAt" + @update:raw-value="(v: string) => information.foundedAtRaw = v" /> import { computed, onMounted, ref } from 'vue' import { useSupplier } from '~/modules/commercial/composables/useSupplier' -import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules' +import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules' import { readHistoryTab } from '~/shared/utils/historyTab' import { canEditSupplier, @@ -280,7 +280,7 @@ import { showRestoreAction, type SelectOption, type SupplierDetail, -} from '~/modules/commercial/utils/supplierConsultation' +} from '~/modules/commercial/utils/forms/supplierConsultation' import { emptyContact } from '~/modules/commercial/types/supplierForm' // Masque d'affichage (purement visuel, la donnee reste celle du serveur). diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue index eaa8602..e30c026 100644 --- a/frontend/modules/commercial/pages/suppliers/new.vue +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -71,6 +71,7 @@ :readonly="isValidated('information')" :editable="true" :error="informationErrors.errors.foundedAt" + @update:raw-value="(v: string) => information.foundedAtRaw = v" /> = {}): Inform description: 'desc', competitors: 'concurrents', foundedAt: '2010-05-01', + foundedAtRaw: '', employeesCount: '42', revenueAmount: '1000000', profitAmount: '50000', @@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa expect(payload.description).toBeNull() expect(payload.directorName).toBeNull() }) + + it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => { + // Saisie malformee : on transmet le texte brut tel quel pour declencher la + // 422 back sur foundedAt (validation autoritaire du format, MUI-44). + expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt) + .toBe('32/13/2026') + // Saisie valide : foundedAtRaw vide -> on envoie la date ISO. + expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt) + .toBe('2010-05-01') + }) }) describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => { diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/forms/__tests__/clientFormRules.spec.ts similarity index 100% rename from frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts rename to frontend/modules/commercial/utils/forms/__tests__/clientFormRules.spec.ts diff --git a/frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts b/frontend/modules/commercial/utils/forms/__tests__/supplierConsultation.spec.ts similarity index 100% rename from frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts rename to frontend/modules/commercial/utils/forms/__tests__/supplierConsultation.spec.ts diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/forms/__tests__/supplierEdit.spec.ts similarity index 93% rename from frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts rename to frontend/modules/commercial/utils/forms/__tests__/supplierEdit.spec.ts index 8d9f3d6..341280f 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/forms/__tests__/supplierEdit.spec.ts @@ -11,7 +11,7 @@ import { mapMainDraft, resolveTabEditability, } from '../supplierEdit' -import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' +import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm' describe('buildMainPayload (groupe supplier:write:main)', () => { @@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => { describe('buildInformationPayload (groupe supplier:write:information)', () => { const base = { - description: null, competitors: null, foundedAt: null, employeesCount: null, + description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null, revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null, } @@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => { }) expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null }) }) + + it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => { + // Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44). + expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt) + .toBe('32/13/2026') + // Saisie valide : foundedAtRaw vide -> on envoie la date ISO. + expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt) + .toBe('2008-04-01') + }) }) describe('buildContactPayload (sous-ressource supplier_contact)', () => { diff --git a/frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts b/frontend/modules/commercial/utils/forms/__tests__/supplierFormRules.spec.ts similarity index 100% rename from frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts rename to frontend/modules/commercial/utils/forms/__tests__/supplierFormRules.spec.ts diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/forms/clientConsultation.ts similarity index 100% rename from frontend/modules/commercial/utils/clientConsultation.ts rename to frontend/modules/commercial/utils/forms/clientConsultation.ts diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/forms/clientEdit.ts similarity index 93% rename from frontend/modules/commercial/utils/clientEdit.ts rename to frontend/modules/commercial/utils/forms/clientEdit.ts index a0d4dff..bfadb0e 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/forms/clientEdit.ts @@ -20,14 +20,14 @@ import { iriOf, relationOf, type ClientDetail, -} from '~/modules/commercial/utils/clientConsultation' +} from '~/modules/commercial/utils/forms/clientConsultation' import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, -} from '~/modules/commercial/utils/clientFormRules' +} from '~/modules/commercial/utils/forms/clientFormRules' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' /** @@ -53,6 +53,13 @@ export interface InformationFormDraft { competitors: string | null /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ foundedAt: string | null + /** + * Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant + * que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au + * back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du + * format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44. + */ + foundedAtRaw: string /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ employeesCount: string | null revenueAmount: string | null @@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft competitors: client.competitors ?? null, // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null, + // Aucune saisie brute invalide au chargement (la valeur stockee est valide). + foundedAtRaw: '', employeesCount: client.employeesCount != null ? String(client.employeesCount) : null, revenueAmount: client.revenueAmount ?? null, profitAmount: client.profitAmount ?? null, @@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco return { description: information.description || null, competitors: information.competitors || null, - foundedAt: information.foundedAt || null, + // Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle + // pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw). + foundedAt: information.foundedAtRaw || information.foundedAt || null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null, revenueAmount: information.revenueAmount || null, profitAmount: information.profitAmount || null, diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/forms/clientFormRules.ts similarity index 100% rename from frontend/modules/commercial/utils/clientFormRules.ts rename to frontend/modules/commercial/utils/forms/clientFormRules.ts diff --git a/frontend/modules/commercial/utils/supplierConsultation.ts b/frontend/modules/commercial/utils/forms/supplierConsultation.ts similarity index 100% rename from frontend/modules/commercial/utils/supplierConsultation.ts rename to frontend/modules/commercial/utils/forms/supplierConsultation.ts diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/forms/supplierEdit.ts similarity index 92% rename from frontend/modules/commercial/utils/supplierEdit.ts rename to frontend/modules/commercial/utils/forms/supplierEdit.ts index 2f8a5e5..cd2ffff 100644 --- a/frontend/modules/commercial/utils/supplierEdit.ts +++ b/frontend/modules/commercial/utils/forms/supplierEdit.ts @@ -17,8 +17,8 @@ import { MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, -} from '~/modules/commercial/utils/supplierFormRules' -import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' +} from '~/modules/commercial/utils/forms/supplierFormRules' +import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation' import type { SupplierAddressFormDraft, SupplierContactFormDraft, @@ -38,6 +38,13 @@ export interface InformationFormDraft { competitors: string | null /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ foundedAt: string | null + /** + * Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant + * que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au + * back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du + * format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44. + */ + foundedAtRaw: string /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ employeesCount: string | null revenueAmount: string | null @@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr competitors: supplier.competitors ?? null, // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null, + // Aucune saisie brute invalide au chargement (la valeur stockee est valide). + foundedAtRaw: '', employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null, revenueAmount: supplier.revenueAmount ?? null, profitAmount: supplier.profitAmount ?? null, @@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco return { description: information.description || null, competitors: information.competitors || null, - foundedAt: information.foundedAt || null, + // Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle + // pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw). + foundedAt: information.foundedAtRaw || information.foundedAt || null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null, revenueAmount: information.revenueAmount || null, profitAmount: information.profitAmount || null, diff --git a/frontend/modules/commercial/utils/supplierFormRules.ts b/frontend/modules/commercial/utils/forms/supplierFormRules.ts similarity index 100% rename from frontend/modules/commercial/utils/supplierFormRules.ts rename to frontend/modules/commercial/utils/forms/supplierFormRules.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43f4fc0..2344110 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "starseed-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.7.8", + "@malio/layer-ui": "^1.7.10", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -1866,9 +1866,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.7.8", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz", - "integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==", + "version": "1.7.10", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz", + "integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/frontend/package.json b/frontend/package.json index a60e49b..b4cf9b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@malio/layer-ui": "^1.7.8", + "@malio/layer-ui": "^1.7.10", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/frontend/shared/composables/__tests__/useFormErrors.test.ts b/frontend/shared/composables/__tests__/useFormErrors.test.ts index 71ed149..b24f289 100644 --- a/frontend/shared/composables/__tests__/useFormErrors.test.ts +++ b/frontend/shared/composables/__tests__/useFormErrors.test.ts @@ -41,6 +41,22 @@ describe('useFormErrors', () => { expect(hasErrors.value).toBe(true) }) + it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => { + const { errors, setServerErrors } = useFormErrors() + const mapped = setServerErrors({ + violations: [ + // Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge. + { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' }, + // Violation metier classique : message back conserve. + { propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }, + ], + }) + expect(mapped).toBe(true) + // Stub i18n -> renvoie la cle telle quelle. + expect(errors.foundedAt).toBe('errors.validation.invalidDate') + expect(errors.companyName).toBe('Obligatoire.') + }) + it('setServerErrors retourne false et ne touche rien sans violation', () => { const { errors, setServerErrors } = useFormErrors() expect(setServerErrors({})).toBe(false) diff --git a/frontend/shared/composables/useFormErrors.ts b/frontend/shared/composables/useFormErrors.ts index 0749569..5fda0b9 100644 --- a/frontend/shared/composables/useFormErrors.ts +++ b/frontend/shared/composables/useFormErrors.ts @@ -17,7 +17,7 @@ * appel par ligne), utiliser directement `mapViolationsToRecord` par ligne. */ import { computed, reactive } from 'vue' -import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' +import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api' /** * Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status @@ -69,13 +69,16 @@ export function useFormErrors() { * violation exploitable). */ function setServerErrors(data: unknown): boolean { - const mapped = mapViolationsToRecord(data) - const keys = Object.keys(mapped) - if (keys.length === 0) return false - for (const key of keys) { - errors[key] = mapped[key] + const violations = extractApiViolations(data) + let mapped = false + for (const v of violations) { + if (!v.propertyPath) continue + // Message back tel quel, sauf code surcharge par une cle i18n (ex. + // erreur de type sur une date non parsable -> « Date invalide »). + errors[v.propertyPath] = resolveViolationMessage(v, t) + mapped = true } - return true + return mapped } /** diff --git a/frontend/shared/utils/__tests__/api.test.ts b/frontend/shared/utils/__tests__/api.test.ts index 7d39644..9b10f1d 100644 --- a/frontend/shared/utils/__tests__/api.test.ts +++ b/frontend/shared/utils/__tests__/api.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { mapViolationsToRecord } from '../api' +import { mapViolationsToRecord, resolveViolationMessage } from '../api' /** * Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des @@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => { expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' }) }) }) + +/** + * Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code + * de violation. Le back peut renvoyer un message technique (erreur de type sur + * une date non parsable) : on le remplace via le `code` Symfony (stable) par une + * cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle. + */ +describe('resolveViolationMessage', () => { + const t = (key: string) => key + // Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige). + const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40' + + it('surcharge le message technique d\'une erreur de type par la cle i18n', () => { + const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR } + expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate') + }) + + it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => { + const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' } + expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.') + }) + + it('renvoie le message back tel quel quand il n\'y a pas de code', () => { + const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' } + expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.') + }) +}) diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 0de396a..bf1081a 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -34,11 +34,15 @@ export function extractHydraMembers(collection: HydraCollection): T[] { /** * Une violation de contrainte API Platform (reponse 422). Le `propertyPath` - * pointe le champ concerne, `message` est le libelle a afficher. + * pointe le champ concerne, `message` est le libelle a afficher, `code` est le + * code de contrainte Symfony (UUID stable, independant de la langue) — il sert + * a surcharger un message back technique par une cle i18n (cf. + * `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`). */ export interface ApiViolation { propertyPath: string message: string + code: string } /** @@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] { out.push({ propertyPath: String(obj.propertyPath ?? ''), message: String(obj.message ?? ''), + code: String(obj.code ?? ''), }) } return out @@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record { return out } +/** + * Surcharge i18n d'un message back par CODE de violation. + * + * La plupart des contraintes back portent deja un message FR explicite (ex. + * `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines + * 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement + * l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas + * denormaliser la valeur (date non parsable envoyee sur un champ + * `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. », + * voire en anglais selon la negociation de langue). + * + * Plutot que de traduire/maquiller cote back, on surcharge ces messages cote + * front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat + * de compatibilite : il ne change pas entre versions), donc bien plus robuste + * qu'un match sur le texte du message (qui depend de la langue). La table + * associe un code -> une cle i18n ; `resolveViolationMessage` l'applique. + * + * Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de + * mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre + * (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le + * libelle « Date invalide ». Si un autre champ typé en saisie libre apparait, + * affiner la resolution via `propertyPath` plutot que par code seul. + */ +export const VIOLATION_MESSAGE_I18N: Record = { + // Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type. + 'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate', +} + +/** + * Resout le message a afficher pour une violation : si son `code` est surcharge + * par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ; + * sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant + * (les utils sont purs, sans acces a useI18n). + */ +export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string { + const i18nKey = VIOLATION_MESSAGE_I18N[v.code] + return i18nKey ? t(i18nKey) : v.message +} + /** * Extrait un message d'erreur lisible depuis un payload Hydra / JSON * d'erreur API Platform. Essaie les champs courants dans l'ordre : diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index c8a033d..f3f2c5c 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -22,8 +22,10 @@ use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => ['client:write:main']], + // Une valeur de mauvais type (ex. date non parsable sur foundedAt) + // doit produire un 422 porte sur le champ (violations[].propertyPath, + // mappable inline par useFormErrors) plutot qu'un 400 generique non + // exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute + // invalide : le back reste la couche autoritaire du format (ERP-101). + collectDenormalizationErrors: true, processor: ClientProcessor::class, ), new Patch( @@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'client:write:accounting', 'client:write:archive', ]], + // Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ + // au lieu d'un 400 generique. Indispensable au mapping inline du + // front (MalioDate MUI-44 forwarde la saisie brute invalide). + collectDenormalizationErrors: true, provider: ClientProvider::class, processor: ClientProcessor::class, ), @@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['client:read', 'client:write:information'])] + // Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans + // ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que + // le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en + // M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute + // saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la + // denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors). + #[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 30709a1..c182e62 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -22,8 +22,10 @@ use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; security: "is_granted('commercial.suppliers.manage')", normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => ['supplier:write:main']], + // Une valeur de mauvais type (ex. date non parsable sur foundedAt) + // doit produire un 422 porte sur le champ (violations[].propertyPath, + // mappable inline par useFormErrors) plutot qu'un 400 generique. Le + // front (MalioDate, MUI-44) forwarde la saisie brute invalide : le + // back reste la couche autoritaire du format (ERP-101). Cf. Client. + collectDenormalizationErrors: true, processor: SupplierProcessor::class, ), new Patch( @@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'supplier:write:accounting', 'supplier:write:archive', ]], + // Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ + // au lieu d'un 400 generique. Indispensable au mapping inline du + // front (MalioDate MUI-44 forwarde la saisie brute invalide). + collectDenormalizationErrors: true, provider: SupplierProvider::class, processor: SupplierProcessor::class, ), @@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['supplier:read', 'supplier:write:information'])] + // Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des + // formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA, + // serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute + // saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client. + #[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] diff --git a/tests/Module/Commercial/Api/ClientFoundedAtFormatTest.php b/tests/Module/Commercial/Api/ClientFoundedAtFormatTest.php new file mode 100644 index 0000000..6cb6c3e --- /dev/null +++ b/tests/Module/Commercial/Api/ClientFoundedAtFormatTest.php @@ -0,0 +1,71 @@ + 422 porte sur foundedAt (et pas un 400 generique). */ + public function testFoundedAtNonParsableEst422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Founded Format SARL'); + + $body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '32/13/2026'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('foundedAt', $this->violationsByPath($body)); + } + + /** + * Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25) + * mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree + * strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422. + */ + public function testFoundedAtFormatAmbiguUsEst422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Founded Ambigu SARL'); + + $body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '12/25/2026'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('foundedAt', $this->violationsByPath($body)); + } + + /** Non-regression : une date ISO valide reste acceptee (200). */ + public function testFoundedAtIsoValideEst200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Founded Ok SARL'); + + $data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '2010-05-01'], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + self::assertStringStartsWith('2010-05-01', $data['foundedAt']); + } +} diff --git a/tests/Module/Commercial/Api/SupplierFoundedAtFormatTest.php b/tests/Module/Commercial/Api/SupplierFoundedAtFormatTest.php new file mode 100644 index 0000000..2451688 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierFoundedAtFormatTest.php @@ -0,0 +1,71 @@ + 422 porte sur foundedAt (et pas un 400 generique). */ + public function testFoundedAtNonParsableEst422(): void + { + $seed = $this->seedSupplier('Founded Format Negoce'); + $credentials = $this->createUserWithPermission('commercial.suppliers.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '32/13/2026'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('foundedAt', $this->violationsByPath($body)); + } + + /** + * Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25) + * mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO + * `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422. + */ + public function testFoundedAtFormatAmbiguUsEst422(): void + { + $seed = $this->seedSupplier('Founded Ambigu Negoce'); + $credentials = $this->createUserWithPermission('commercial.suppliers.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '12/25/2026'], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('foundedAt', $this->violationsByPath($body)); + } + + /** Non-regression : une date ISO valide reste acceptee (200). */ + public function testFoundedAtIsoValideEst200(): void + { + $seed = $this->seedSupplier('Founded Ok Negoce'); + $credentials = $this->createUserWithPermission('commercial.suppliers.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['foundedAt' => '2010-05-01'], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + self::assertStringStartsWith('2010-05-01', $data['foundedAt']); + } +} From b36520d3b1b70e63214d4b0e1f02341a19c52444 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Fri, 12 Jun 2026 08:45:47 +0000 Subject: [PATCH 2/3] chore: bump version to v0.1.110 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 992cf62..03c8d72 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.109' + app.version: '0.1.110' From d97b9ce6d0c441f1a15d056b5676186d28314e2a Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Fri, 12 Jun 2026 14:19:14 +0000 Subject: [PATCH 3/3] feat(technique) : module Technique + taxonomie categories prestataires (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## M3 — Ticket 1.1 : module Technique + taxonomie catégories prestataires Prérequis de tout le M3 (répertoire prestataires). Spec : `docs/specs/M3-prestataires/spec-back.md` § 2.1 + § 2.4. ### Contenu - **Nouveau module `Technique`** (`src/Module/Technique/TechniqueModule.php`) : `ID=technique`, `LABEL=Technique`, `REQUIRED=false`, `permissions()` (5 codes `technique.providers.*` : view / manage / accounting.view / accounting.manage / archive). - Activation dans `config/modules.php` → `/api/modules` expose `technique`. - **Layer front** `frontend/modules/technique/` (auto-détecté). - **Seed taxonomie PRESTATAIRE** : nouveau `CategoryType` (code `PRESTATAIRE` / label `Prestataire`) + 3 catégories (Maintenance industrielle, Nettoyage, Transport). - Migration racine idempotente `Version20260612080000` (`ON CONFLICT DO NOTHING` + `NOT EXISTS`, jonction M2M `category_category_type` — schéma courant, pas l'ancien `category_type_id`). - Fixtures `CategoryTypeFixtures` / `CategoryFixtures` étendues (survivent au purger `db-reset`). ### Critères d'acceptation ✅ - [x] Module + permissions déclarées (`app:sync-permissions` → 5 codes en base) - [x] `TechniqueModule::class` dans `config/modules.php` - [x] Layer front - [x] Seed CategoryType PRESTATAIRE (migration + fixture idempotente) - [x] ≥ 3 catégories PRESTATAIRE - [x] `GET /api/categories?typeCode=PRESTATAIRE` filtre correctement ### Tests - `TechniqueModuleTest` : identité + jeu de 5 permissions figé. - `CategoryPrestataireSeedTest` : `?typeCode=PRESTATAIRE` ne renvoie QUE le type PRESTATAIRE + pagination Hydra préservée. - `make test` : **589 tests OK** · `php-cs-fixer` : 0 correction · `make db-reset` : type + 3 catégories présents, idempotent. ### Hors-périmètre (tickets M3 suivants) Section sidebar « Technique », personas RBAC E2E, et entités `Provider*` (l'écran `/providers` n'existe pas encore → pas de lien mort introduit ici). --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/89 --- config/modules.php | 2 + docs/specs/M3-prestataires/spec-back.md | 1013 +++++++++++++++++ docs/specs/M3-prestataires/spec-front.md | 339 ++++++ docs/specs/_RETEX-M1-pour-M2.md | 80 ++ frontend/modules/technique/nuxt.config.ts | 1 + migrations/Version20260612080000.php | 121 ++ .../DataFixtures/CategoryFixtures.php | 9 +- .../DataFixtures/CategoryTypeFixtures.php | 9 +- src/Module/Technique/TechniqueModule.php | 58 + .../Api/CategoryPrestataireSeedTest.php | 107 ++ .../Module/Technique/TechniqueModuleTest.php | 59 + 11 files changed, 1795 insertions(+), 3 deletions(-) create mode 100644 docs/specs/M3-prestataires/spec-back.md create mode 100644 docs/specs/M3-prestataires/spec-front.md create mode 100644 docs/specs/_RETEX-M1-pour-M2.md create mode 100644 frontend/modules/technique/nuxt.config.ts create mode 100644 migrations/Version20260612080000.php create mode 100644 src/Module/Technique/TechniqueModule.php create mode 100644 tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php create mode 100644 tests/Module/Technique/TechniqueModuleTest.php diff --git a/config/modules.php b/config/modules.php index c4f8f54..1681bb8 100644 --- a/config/modules.php +++ b/config/modules.php @@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule; use App\Module\Commercial\CommercialModule; use App\Module\Core\CoreModule; use App\Module\Sites\SitesModule; +use App\Module\Technique\TechniqueModule; return [ CoreModule::class, CommercialModule::class, SitesModule::class, CatalogModule::class, + TechniqueModule::class, ]; diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md new file mode 100644 index 0000000..b9a1dbd --- /dev/null +++ b/docs/specs/M3-prestataires/spec-back.md @@ -0,0 +1,1013 @@ +--- +# === IDENTITÉ === +module: M3 +nom: "Répertoire prestataires" +ecran: repertoire-prestataires +owner_spec: Matthieu +backup_spec: Tristan +version: V0.2 +date_redaction: 2026-06-11 +# Historique : V0.2 (2026-06-11) — Spec back initiale, miroir M2 (fournisseurs). +# Alignement refonte-contact (pas de contact inline sur le formulaire principal). +# Différences M3 : pas d'onglet Information ; site sur le formulaire principal (provider_site) ; +# adresse simple (pas de type/bennes/triage) ; nouveau pôle Technique. + +# === LIENS === +spec_front: ./spec-front.md +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6) +lesstime_project_id: 6 +statut_global: en_dev + +# === DÉPENDANCES AMONT === +depend_de: + - M2-suppliers # pattern jumeau Supplier* répliqué en Provider* ; référentiels compta partagés + - M1-clients # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + filtre ?typeCode= (créé au M2) + - M0-categories # Category + CategoryType (étendu par seed M3 : type PRESTATAIRE) + - Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place + - Core # User, Role, Permission, Audit, JWT déjà en place + - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) +--- + +# Spec back — Module 3 : Répertoire prestataires + +## 1. Contexte + +Cette spec **complète et précise** la [spec front V0.2](./spec-front.md) (`M3-reportoire-prestataires.docx` du 04/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion, tests, hors-périmètre. + +**Module cible** : **nouveau module `Technique`** (`src/Module/Technique/`). Le prestataire est le **jumeau du fournisseur** (`Provider` / `ProviderContact` / `ProviderAddress` / `ProviderRib`), construit sur le pattern éprouvé M1/M2. Voir § 2.1 pour la justification du module séparé et la consommation des référentiels comptables. + +**Dépendances déjà en place sur `develop`** (héritées M1/M2) : +- `Commercial` → référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication**, consommées en relation ORM). +- `Catalog` (M0) → `Category` + `CategoryType` + **filtre `?typeCode=` opérationnel** (créé au M2). Le M3 ajoute le type `PRESTATAIRE`. +- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82). +- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52). +- `Core` → User, Role, Permission, Audit, JWT. + +> **RETEX obligatoire** : lire [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) AVANT de coder. ~80 % des frictions M1 venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M3. + +## 2. Décisions d'archi + +### 2.1 Module — Nouveau module `Technique`, entités jumelles de `Supplier` + +> **⚠️ Décision à confirmer (Matthieu, 11/06/2026)** : le docx place le répertoire prestataires dans un **Module « Technique »**, confirmé comme **pôle distinct du Commercial**. On crée donc un **nouveau module back `Technique`** : +> - `src/Module/Technique/TechniqueModule.php` : `ID = 'technique'`, `LABEL = 'Technique'`, `REQUIRED = false`, méthode `permissions()` (cf. § 5.1). +> - Activation : ajouter `TechniqueModule::class` dans `config/modules.php`. +> - Front : layer Nuxt `frontend/modules/technique/` (auto-détecté) + nouvelle **section sidebar « Technique »** dans `config/sidebar.php`. + +Le prestataire M3 **réplique à l'identique** le pattern `Supplier*` du M2 sous `Provider*` (tables dédiées, pas de table polymorphe partagée — clients / fournisseurs / prestataires divergent fonctionnellement, l'isolation prime). + +**Référentiels comptables & Category — consommation cross-module (relation ORM partagée, PAS d'import de logique)** : `Provider` référence `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (module Commercial) et `Category` / `Site` (modules Catalog / Sites) via des **relations ORM** (ManyToOne / ManyToMany), **exactement comme `Supplier` (Commercial) référence déjà `Site` (Sites) et `Category` (Catalog)**. Ce sont des **données de référence partagées**, pas de la logique inter-module : aucun service / repository d'un autre module n'est appelé. La règle ABSOLUE n°1 (« ne jamais importer d'un module à un autre — passer par Shared/Contract ») vise les **dépendances de logique métier** ; le projet a déjà acté (M1/M2) que la **référence ORM à une entité de référence partagée** est tolérée et documentée comme telle. + +> **Décision Matthieu (11/06) : on fait « comme supplier »** — consommation ORM partagée des référentiels comptables (zéro refacto, zéro duplication). La remontée dans `Shared` (isolation stricte) reste une option future non retenue au M3 (tracé HP-M4-2). + +### 2.2 IDs entier auto-increment Postgres natif + +Cohérent avec M0/M1/M2 et l'ensemble Starseed. Pas d'UUID, pas de ULID. PK en `INT GENERATED BY DEFAULT AS IDENTITY` (style aligné M1/M2), horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`). + +### 2.3 Référentiels comptables — réutilisation M1/M2 (zéro duplication) + +Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ entités lecture seule et seeds) sont **celles du M1**. Le M3 ne crée **aucune** nouvelle table de référentiel comptable : `provider.tva_mode_id`, `provider.payment_delay_id`, `provider.payment_type_id`, `provider.bank_id` pointent vers les mêmes tables. + +Endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà. **Évolution M3** : élargir leur `security` pour autoriser **aussi** les rôles prestataires (cf. § 4.7). Les codes pivots `VIREMENT` (RG-3.07) et `LCR` (RG-3.08) existent déjà dans `payment_types`. + +### 2.4 Catégories — nouveau `CategoryType` `PRESTATAIRE` + +Le multi-select « Catégorie » du prestataire (formulaire principal ET adresse) référence des `Category` rattachées à un **nouveau `CategoryType` de code `PRESTATAIRE`** (label « Prestataire »), seedé par le M3. On assume des **types distincts** (`CLIENT` / `FOURNISSEUR` / `PRESTATAIRE`) — chacun avec sa taxonomie. + +> **Bonne nouvelle vs M2** : le **filtre `?typeCode=` a été implémenté au M2** sur `/api/categories` (module Catalog). Le M3 n'a donc **plus à le créer** : il suffit de **seeder le type `PRESTATAIRE`** + ses catégories (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). **À vérifier sur le JSON réel** que `GET /api/categories?typeCode=PRESTATAIRE` filtre bien (DoD de la spec). + +> **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché front = `category.name`**. Les M2M `provider_category` / `provider_address_category` ne contraignent que des `Category` de type `PRESTATAIRE` (RG-3.09). + +### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1/M2) + +| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur | +|---|---|---|---|---| +| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `technique.providers.archive` | +| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M4+ | Aucun rôle au M3 (HP) | + +Conséquences (miroir M2) : +- `DELETE /api/providers/{id}` **non exposé** au M3 (404 si appelé). +- `GET /api/providers?includeArchived=true` permet de voir les archivés (permission `technique.providers.view`). +- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure. +- L'unicité métier ignore les archivés ET les soft-deletés (cf. § 2.6). + +### 2.6 Unicité partielle Postgres — nom de société + +> **Décision à confirmer (alignée Q4 M1 / § 2.6 M2)** : l'unicité métier porte **uniquement sur le nom de prestataire** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques. + +Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `ProviderProcessor`. + +### 2.7 Audit & traces temporelles + +Pattern Starseed standard, miroir M1/M2 : +- `#[Auditable]` sur `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib`. +- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `ProviderRib.iban` et `ProviderRib.bic` (audit admin-only côté Starseed → traçabilité comptable). +- Audit M2M automatique sur `provider.categories` et `provider.sites` (`{categories: {added:[...], removed:[...]}}`). +- **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.technique_provider`, `audit.entity.technique_providercontact`, `audit.entity.technique_provideraddress`, `audit.entity.technique_providerrib` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`). + +### 2.8 Timestampable + Blamable + +`Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait`. Migration : 4 colonnes par table (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`) + commentaires via le helper `addStandardTimestampableBlamableComments($schema, '')`. + +### 2.9 Permissions RBAC — granularité (5 permissions, identique M2) + +| Permission | Admin | Bureau | Compta | Commerciale | Usine | +|---|---|---|---|---|---| +| `technique.providers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ✅ (cloisonné par site — § 2.13) | +| `technique.providers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ | +| `technique.providers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `technique.providers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `technique.providers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | + +Notes (miroir M2) : +- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un prestataire existant. Pas de création (pas de `manage` global). +- **Commerciale** : `view` + `manage` mais **pas** `accounting.view` → onglet Comptabilité masqué (front) et filtré (back) via le `ProviderReadGroupContextBuilder` (gating **par ajout** de groupe `provider:read:accounting`, jamais par retrait). Sans la permission, scalaires compta + `ribs` ne sont jamais sérialisés. +- **Bureau** : `view` + `manage` (tout sauf Comptabilité). +- **Usine** : `view` (lecture seule, pas de `manage`), **cloisonné par site** — voir § 2.13. +- **⚠️ Le « Tout » vs « son site uniquement » de la colonne Consultation du docx n'est PAS porté par le rôle** : c'est un **cloisonnement par site piloté par l'utilisateur** (décision Matthieu, 11/06). Tout user voit par défaut les prestataires de **son site courant** ; les profils qui doivent voir **tous les sites** (Admin, et selon besoin Bureau/Compta/Commerciale) l'obtiennent via la permission `sites.bypass_scope` (Admin l'a par bypass total). Mécanique complète en § 2.13. + +### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M2) + +`Provider` créé en BDD **dès validation du formulaire principal** via `POST /api/providers`. Onglets suivants → **PATCH partiels** avec groupes de sérialisation dédiés : + +- `provider:write:main` — formulaire principal (POST + PATCH) : `companyName`, `categories`, `sites` +- `provider:write:contacts` — onglet Contact (sous-ressource `provider_contact`) +- `provider:write:addresses` — onglet Adresse (sous-ressource `provider_address`) +- `provider:write:accounting` — onglet Comptabilité (security séparée) +- `provider:write:archive` — toggle archive (security `technique.providers.archive`) + +**Pas de groupe `provider:write:information`** (pas d'onglet Information au M3). **Pas de state machine** côté back (pas de `status = draft|active`). + +### 2.11 Normalisation serveur des entrées texte (identique M1/M2) + +`ProviderFieldNormalizer` (miroir `SupplierFieldNormalizer`), service interne appelé par les Processors avant validation : + +```php +final class ProviderFieldNormalizer +{ + public function normalizeCompanyName(?string $v): ?string // mb_strtoupper(trim) + public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE + public function normalizeEmail(?string $v): ?string // mb_strtolower(trim) + public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '') +} +``` + +Le formatage `XX XX XX XX XX` est fait à l'affichage front. Le back stocke `0612345678` (chiffres seuls). + +### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/M2) + +La **liste** `GET /api/providers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme M1/M2. + +> **Différence M3 (importante)** : au M2, `sites[]` de la liste était l'**agrégat dédoublonné des adresses** (`Supplier::getSites()`). Au M3, le **prestataire porte directement des sites** (formulaire principal — RG-3.03, M2M `provider_site`). La colonne « Site » de la liste affiche donc **`provider.sites` (relation directe)**, pas un agrégat d'adresses. Plus simple et plus performant. + +Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS les to-many dans la requête de liste (filtres + tri seulement) ; `hydrateListCollections()` remplit `categories` puis `sites` (relation directe) via des requêtes `IN` bornées séparées sur les mêmes instances (identity map), pour éviter le produit cartésien sur les chemins non paginés (export, `?pagination=false`). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité. + +### 2.13 Cloisonnement par site — visibilité pilotée par l'utilisateur (DÉCISION M3) + +> **Décision Matthieu (11/06/2026)** : la visibilité des prestataires est **cloisonnée par site, automatiquement, côté back, en fonction de l'utilisateur** — **pas du rôle**. Un user a un (ou des) site(s) (`user_site`, + un `currentSite` actif). Il ne voit que les prestataires **rattachés à son site**. Les profils qui doivent voir tous les sites passent par `sites.bypass_scope` (Admin l'a par bypass total). Le rôle « Usine » n'est qu'un cas particulier de cette règle générale. + +**Réutilisation de l'infra Sites existante** (`docs/modules/site-aware.md`) : `CurrentSiteProvider` (site courant de l'user), permission `sites.bypass_scope` (voit tous les sites — Admin automatique), users ↔ sites via M2M `user_site`. + +**⚠️ Pourquoi PAS `SiteAwareInterface` standard** : le pattern opt-in `SiteAwareInterface` + `SiteScopedQueryExtension` est **mono-site** (`site_id INT NOT NULL`, ManyToOne unique, filtre `x.site = :currentSite`). Or le prestataire est **multi-site** (M2M `provider_site`, ≥ 1 — RG-3.03). Le pattern standard ne s'applique donc pas tel quel. On câble un **filtre de cloisonnement custom multi-site** (cas explicitement renvoyé au module par `site-aware.md § 6.1 / § 6.2`), qui réutilise `CurrentSiteProvider` + `sites.bypass_scope` : + +- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter). +- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre). +- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses). +- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`). + +> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle). + +> **Index** : `idx_provider_site_site` sur `provider_site(site_id)` (déjà prévu § 3.2) sert le filtre `WHERE site = :currentSite`. + +## 3. Modèle de données + +### 3.1 Diagramme + +``` ++----------------------+ +--------------------------+ +-----------------+ +| provider |--n:m-->| provider_category |<--n:m--| category | +| | +--------------------------+ | type=PRESTATAIRE| +| id (PK) | +-----------------+ +| company_name |--n:m-->| provider_site |<--n:m--| site (Sites) | +| is_archived | +--------------------------+ | (RG-3.03) | +| archived_at | +-----------------+ +| deleted_at | +--------------------------+ +| -- Comptabilité -- |--1:n-->| provider_contact | +| siren / account_num | +--------------------------+ +| tva_mode_id | +-----------------+ +| n_tva | +--------------------------+ | tva_mode (M1) | +| payment_delay_id |--1:n-->| provider_address | | payment_* (M1) | +| payment_type_id | +--------------------------+ | bank (M1) | +| bank_id (nullable) | | (PAS de address_type) +-----------------+ ++----------------------+ +--n:m--> site + +--n:m--> provider_contact + +--------------------------+ +--n:m--> category (PRESTATAIRE) + | provider_rib | + +--------------------------+ + label / bic / iban +``` + +**Particularités M3 (différences vs `supplier`)** : +- **PAS d'onglet Information** : aucun champ `description` / `competitors` / `founded_at` / `employees_count` / `revenue_amount` / `director_name` / `profit_amount` / `volume_forecast`. Le `provider` est minimal : nom + comptabilité. +- **`provider.sites` (M2M `provider_site`)** : sélecteur de site **sur le formulaire principal** (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse). +- **`provider_address` simplifiée** : **pas** de `address_type`, **pas** de `bennes`, **pas** de `triage_provider`. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[]. +- Les référentiels comptables (`tva_mode`...) **ne sont pas recréés** — FK vers les tables M1. + +### 3.2 Migration Doctrine — SQL Postgres + +Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater par le dev). + +> **Même justification qu'au M1/M2** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType PRESTATAIRE` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente en dev/test). + +> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est *illustratif* (style aligné M1/M2 : `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE`). + +```sql +-- ===================================================================== +-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés) +-- ===================================================================== +INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire') + ON CONFLICT (code) DO NOTHING; + +-- ===================================================================== +-- Table principale `provider` +-- ===================================================================== +CREATE TABLE provider ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + -- Formulaire principal + company_name VARCHAR(180) NOT NULL, + -- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...) + -- Onglet Comptabilité (FK référentiels M1 — partagés) + siren VARCHAR(20), + account_number VARCHAR(40), + tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT, + n_tva VARCHAR(40), + payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT, + payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT, + bank_id INT REFERENCES bank(id) ON DELETE RESTRICT, + -- Archive (exposé M3) + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE, + -- Soft delete (préparé, non exposé au M3) + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, + -- Timestampable + Blamable + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); + +CREATE INDEX idx_provider_is_archived ON provider(is_archived); +CREATE INDEX idx_provider_deleted_at ON provider(deleted_at); +CREATE INDEX idx_provider_created_by ON provider(created_by); +CREATE INDEX idx_provider_updated_by ON provider(updated_by); + +-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6) +CREATE UNIQUE INDEX uq_provider_company_name_active + ON provider (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL; + +-- ===================================================================== +-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09) +-- ===================================================================== +CREATE TABLE provider_category ( + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_id, category_id) +); +CREATE INDEX idx_provider_category_category ON provider_category(category_id); + +-- ===================================================================== +-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03) +-- ===================================================================== +CREATE TABLE provider_site ( + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_id, site_id) +); +CREATE INDEX idx_provider_site_site ON provider_site(site_id); + +-- ===================================================================== +-- Sous-collection : Contacts (1:n) +-- ===================================================================== +CREATE TABLE provider_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + first_name VARCHAR(120), + last_name VARCHAR(120), + job_title VARCHAR(120), + phone_primary VARCHAR(20), + phone_secondary VARCHAR(20), + email VARCHAR(180), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, + -- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous + -- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2) + CONSTRAINT chk_provider_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL) +); +CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id); + +-- ===================================================================== +-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage +-- ===================================================================== +CREATE TABLE provider_address ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + country VARCHAR(80) NOT NULL DEFAULT 'France', + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(120) NOT NULL, + street VARCHAR(255) NOT NULL, + street_complement VARCHAR(255), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); +CREATE INDEX idx_provider_address_provider ON provider_address(provider_id); + +-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site) +CREATE TABLE provider_address_site ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_address_id, site_id) +); + +-- M2M provider_address ↔ provider_contact +CREATE TABLE provider_address_contact ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE, + PRIMARY KEY (provider_address_id, provider_contact_id) +); + +-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09) +CREATE TABLE provider_address_category ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_address_id, category_id) +); + +-- ===================================================================== +-- Sous-collection : RIB (1:n) +-- ===================================================================== +CREATE TABLE provider_rib ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + label VARCHAR(120) NOT NULL, + bic VARCHAR(20) NOT NULL, + iban VARCHAR(34) NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); +CREATE INDEX idx_provider_rib_provider ON provider_rib(provider_id); +``` + +### 3.2.bis Commentaires SQL obligatoires (échantillon) + +```php +$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'"); +$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'"); +$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'"); +$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'"); +$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'"); +// provider_site (M2M) : commenter via COMMENT ON TABLE +$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'"); +$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'"); +// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12) +$this->addStandardTimestampableBlamableComments($schema, 'provider'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_contact'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_address'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_rib'); +``` + +### 3.3 Entité `Provider` — squelette (extrait) + +Miroir de `Supplier` (cf. [`../M2-suppliers/spec-back.md § 3.3`](../M2-suppliers/spec-back.md)), **amputé de l'onglet Information** et **augmenté de `sites` (relation directe)**. + +```php + ['provider:read', 'category:read', 'site:read', 'default:read']], + provider: ProviderProvider::class, + ), + new Get( + security: "is_granted('technique.providers.view')", + // Détail embarque sous-collections (contacts, addresses, ribs) + relations imbriquées. + // provider:read:accounting AJOUTÉ dynamiquement par le ReadGroupContextBuilder si accounting.view. + normalizationContext: ['groups' => [ + 'provider:read', 'provider:item:read', + 'category:read', 'site:read', 'default:read', + ]], + provider: ProviderProvider::class, + ), + new Post( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:main']], + processor: ProviderProcessor::class, + ), + new Patch( + // Security élargie : manage OU accounting.manage (Compta édite la compta sans manage global). + security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'provider:write:main', 'provider:write:accounting', 'provider:write:archive', + ]], + provider: ProviderProvider::class, + processor: ProviderProcessor::class, + ), + // Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }. + ], +)] +#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)] +#[ORM\Table(name: 'provider')] +#[Auditable] +class Provider implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + #[Groups(['provider:read'])] + private ?int $id = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Groups(['provider:read', 'provider:write:main'])] + private ?string $companyName = null; + + /** @var Collection Catégories de type PRESTATAIRE (RG-3.09) */ + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'provider_category')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] + #[Groups(['provider:read', 'provider:write:main'])] + private Collection $categories; + + /** @var Collection Sites du prestataire — sélecteur du formulaire principal (RG-3.03) */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'provider_site')] + #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] + #[Groups(['provider:read', 'provider:write:main'])] + private Collection $sites; + + // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M2) === + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $siren = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?PaymentDelay $paymentDelay = null; + + #[ORM\ManyToOne(targetEntity: PaymentType::class)] + #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?PaymentType $paymentType = null; + + #[ORM\ManyToOne(targetEntity: Bank::class)] + #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) === + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:item:read'])] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:item:read'])] + private Collection $addresses; + + /** @var Collection RIB embarqués dans le groupe COMPTA (gated par le Provider) */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:read:accounting'])] + private Collection $ribs; + + // === Archive / Soft delete === + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + private bool $isArchived = false; + + // ⚠ PIÈGE BOOLÉEN (bug #3 M1) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER, + // sinon Symfony strip "is" → attribut "archived" → clé droppée. À tester sur JSON réel. + #[Groups(['provider:read', 'provider:write:archive'])] + #[SerializedName('isArchived')] + public function isArchived(): bool + { + return $this->isArchived; + } + // ... archivedAt, getters/setters, __construct (ArrayCollection) ... +} +``` + +### 3.4 Squelettes des autres entités + +Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `provider_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) : + +**`ProviderContact`** — propriétés dans `['provider:item:read', 'provider:write:contacts']` : +`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `provider.contacts` au détail ; éditables via la sous-ressource. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`). + +**`ProviderAddress`** — propriétés dans `['provider:item:read', 'provider:write:addresses']` : +`country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. **PAS** de `addressType` / `bennes` / `triageProvider`. Relations imbriquées (maillon (c) — read-groups dans le contexte du `Get` racine) : +- M2M `sites` → `#[Groups(['provider:item:read'])]` ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`**) (`Assert\Count(min:1)` — RG-3.05). +- M2M `contacts` → `#[Groups(['provider:item:read'])]` ; embarque des `ProviderContact`. +- M2M `categories` → `#[Groups(['provider:item:read'])]` ; `Category` (id/code/name, type PRESTATAIRE — RG-3.09). + +**`ProviderRib`** — propriétés dans `['provider:read:accounting', 'provider:write:accounting']` : +`label`, `bic`, `iban`, `id`. Embed sous `provider.ribs` **uniquement** si l'user a `accounting.view`. Aucun `#[AuditIgnore]` sur `iban`/`bic`. + +> ⚠ `Site` / `Category` / référentiels comptables appartiennent à d'autres modules — on consomme leurs read-groups (`site:read`, `category:read`, `provider:read:accounting` pour les réfs compta), **pas de logique inter-module** (§ 2.1). + +## 4. API REST (API Platform) + +### 4.0 Contrat de sérialisation (RETEX M1 — section critique) + +> **Leçon M1/M2** : ~80 % des frictions venaient du contrat de sérialisation. Pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent. + +**Contexte par opération** : + +| Opération | `normalizationContext` (groupes) | +|---|---| +| `GetCollection` (liste) | `provider:read` + `category:read` + `site:read` + `default:read` | +| `Get` (détail) | `provider:read` + `provider:item:read` + `provider:read:accounting`¹ + `category:read` + `site:read` + `default:read` | + +¹ `provider:read:accounting` retiré par le `ProviderProvider` / `ProviderReadGroupContextBuilder` si l'user n'a pas `technique.providers.accounting.view`. + +**LISTE — champ datatable → maillons** : + +| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) | +|---|---|---|---| +| Nom | `companyName` ∈ `provider:read` | ✅ | — | +| Catégories | `categories` ∈ `provider:read` (embed) | ✅ | `category:read` ✅ (code/**name**) | +| Site | `sites` ∈ `provider:read` (embed, relation **directe** — RG-3.03) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) | +| Dernière activité | `updatedAt` ∈ `provider:read` | ✅ | — | + +**DÉTAIL — champ → maillons** : + +| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) | +|---|---|---|---| +| Scalaires principaux | `provider:read` | ✅ | — | +| `categories[]` (id/code/name) | `categories` ∈ `provider:read` | ✅ | `category:read` ✅ | +| `sites[]` (formulaire principal) | `sites` ∈ `provider:read` | ✅ | `site:read` ✅ | +| `contacts[]` (5 champs) | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ | +| `addresses[]` (scalaires) | `addresses` ∈ `provider:item:read` | ✅ | propriétés `ProviderAddress` ∈ `provider:item:read` ✅ | +| `addresses[].sites[]` | `sites` ∈ `provider:item:read` | ✅ | `site:read` ✅ | +| `addresses[].categories[]` | `categories` ∈ `provider:item:read` | ✅ | `category:read` ✅ | +| `addresses[].contacts[]` | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ | +| Scalaires Comptabilité | `provider:read:accounting` | ✅ (gated) | réfs (`tvaMode`…) id+label ∈ `provider:read:accounting` | +| `ribs[]` (label/bic/iban) | `ribs` ∈ `provider:read:accounting` | ✅ (gated) | — | + +### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle) + +> **Definition of Done** (miroir ERP-92 du M2) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`ProviderSerializationContractTest`, prestataire complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel** (règle anti-régression M2). +> +> **2 pièges hérités M1/M2 à re-tester sur le M3** : +> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`). +> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`). + +`GET /api/providers` (liste, ADMIN — un membre, forme attendue) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers", + "@type": "Collection", + "totalItems": 1, + "member": [ + { + "@id": "/api/providers/1", "@type": "Provider", "id": 1, + "companyName": "MAINTENANCE PRO SAS", + "categories": [ + {"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE", + "categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"} + ], + "siren": "987654321", "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} + ], + "updatedAt": "2026-06-11T10:00:00+02:00", + "isArchived": false + } + ], + "view": {"@id": "/api/providers", "@type": "PartialCollectionView"} +} +``` + +> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre. + +`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) : +```json +{ + "@id": "/api/providers/1", "@type": "Provider", "id": 1, + "companyName": "MAINTENANCE PRO SAS", + "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}], + "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], + "siren": "987654321", "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, + "contacts": [ + {"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} + ], + "addresses": [ + {"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], + "contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}], + "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]} + ], + "ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}], + "isArchived": false +} +``` + +> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test). + +### 4.1 `GET /api/providers` — Liste + +- **Security** : `is_granted('technique.providers.view')` +- **Query params** (alimentent le panneau « Filtrer ») : + - `includeArchived=true|false` (default `false`) + - `categoryCode=` (filtre les prestataires ayant ≥ 1 `Category` de ce code ; répétable) + - `siteId=` (filtre via la relation **directe** `provider.sites` ; répétable) — *NB : au M3 le site est porté par le prestataire, le filtre joint `provider_site` (pas les adresses).* + - `search=` (fuzzy sur `companyName` + contacts liés `provider_contact` (firstName / lastName / email) via LEFT JOIN groupé par `provider.id`) +- **Tri par défaut** : `companyName ASC` +- **Cloisonnement par site (§ 2.13)** : si l'user **n'a pas** `sites.bypass_scope`, la liste est filtrée sur les prestataires dont `provider.sites` contient le `currentSite` (RG-3.17). Transparent pour le client (pas de query param). +- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `ProviderProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. ⚠️ Le filtre de cloisonnement s'applique **avant** la pagination (le `totalItems` reflète le périmètre de l'user). +- **Anti N+1 (§ 2.12)** : hydratation des `categories` + `sites` via requêtes `IN` bornées séparées (pas de fetch-join combiné). +- **Codes** : `200` / `401` / `403` + +### 4.2 `GET /api/providers/{id}` — Détail + +- **Security** : `is_granted('technique.providers.view')` +- **Comportement** : prestataire + contacts + adresses + RIBs. Champs `provider:read:accounting` inclus seulement si `technique.providers.accounting.view`. +- **Cloisonnement par site (§ 2.13)** : un user sans `sites.bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (ne pas révéler l'existence hors périmètre — RG-3.17). +- **Codes** : `200` / `404` / `401` / `403` + +### 4.3 `POST /api/providers` — Création (formulaire principal) + +- **Security** : `is_granted('technique.providers.manage')` +- **Body** (groupe `provider:write:main`) : +```json +{ + "companyName": "MAINTENANCE PRO SAS", + "categories": ["/api/categories/300"], + "sites": ["/api/sites/87"] +} +``` +- **Réponse 201** : le prestataire créé avec son `id`. Le front enchaîne les PATCH par onglet. +- **Codes** : + - `201` / `400` / `401` / `403` + - `409 Conflict` si doublon de nom (`companyName` — RG-3.10). SIREN/email non uniques. + - `422` : catégories vides (RG-3.09) ; sites vides (RG-3.03) ; catégorie hors type PRESTATAIRE (RG-3.09). + +### 4.4 `PATCH /api/providers/{id}` — Modification + +- **Security base** : `is_granted('technique.providers.manage')` +- **Security additionnelle** (dans le `ProviderProcessor`) : + - payload contenant un champ `provider:write:accounting` → exige `technique.providers.accounting.manage` + - payload contenant `isArchived` → exige `technique.providers.archive` + - **mode strict** (RG-3.15) : payload mélangeant des groupes hors permissions → 403 sur tout le payload. +- **Body** : merge-patch+json, champs modifiés uniquement. +- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422` + +### 4.5 Sous-ressources + +**Contacts** : `POST /api/providers/{id}/contacts`, `PATCH /api/provider_contacts/{id}`, `DELETE /api/provider_contacts/{id}`. +- **Security** : `is_granted('technique.providers.manage')` +- **RG-3.12** : au moins 1 bloc Contact valide pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine). + +**Adresses** : `POST /api/providers/{id}/addresses`, `PATCH /api/provider_addresses/{id}`, `DELETE /api/provider_addresses/{id}`. +- **Security** : `is_granted('technique.providers.manage')` +- Validations : ≥ 1 site (RG-3.05) ; catégories de type PRESTATAIRE uniquement (RG-3.09) ; `postalCode` matche `^[0-9]{4,5}$` (RG-3.06). + +**RIBs** : `POST /api/providers/{id}/ribs`, `PATCH /api/provider_ribs/{id}`, `DELETE /api/provider_ribs/{id}`. +- **Security** : `is_granted('technique.providers.accounting.manage')` +- **RG-3.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409. + +### 4.6 `GET /api/providers/export.xlsx` — Export + +- **Security** : `is_granted('technique.providers.view')` +- **Comportement** : XLSX des prestataires **affichés** (mêmes filtres que la liste, non archivés par défaut). +- Colonnes : Nom prestataire, Contact principal (Nom + Prénom), Téléphone principal, Téléphone secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis si pas `accounting.view`), Date de création. _(Colonnes contact alimentées depuis le contact principal `provider_contact` de plus petit `position`.)_ +- **Implémentation** : controller custom `ProviderExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente). +- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-prestataires-{YYYYMMDD}.xlsx"` + +### 4.7 Référentiels (réutilisés M1/M2 — évolution security) + +`GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent. **Évolution M3** : élargir leur `security` pour autoriser aussi les rôles prestataires, p.ex. `... or is_granted('technique.providers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP). + +`GET /api/categories?typeCode=PRESTATAIRE` alimente les multi-selects Catégorie (prestataire + adresse). ✅ **Le filtre `?typeCode=` existe** (créé au M2) — il suffit de **seeder le type `PRESTATAIRE`** + ses catégories. **À vérifier** que le filtre fonctionne pour ce nouveau type (DoD). + +## 5. Autorisation + +### 5.1 Déclaration des permissions + +Créer `TechniqueModule::permissions()` : + +```php +['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'], +['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'], +['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'], +['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'], +['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'], +``` + +Synchronisation : `php bin/console app:sync-permissions`. + +### 5.2 Mapping rôles MALIO ↔ permissions + +Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `technique.providers`) + § 2.13 (cloisonnement par site via `sites.bypass_scope`). **Attribution `sites.bypass_scope` par défaut** : Admin (auto) + Bureau + Compta + Commerciale ; **Usine non** (cloisonnée à son site). + +### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) + +1. **`config/sidebar.php`** — **nouvelle section « Technique »** + item : +```php +[ + 'key' => 'technique', + 'label' => 'sidebar.technique.section', + 'items' => [ + [ + 'label' => 'sidebar.technique.providers', + 'to' => '/providers', + 'icon' => 'mdi:account-wrench-outline', + 'module' => 'technique', + 'permission' => 'technique.providers.view', + ], + ], +], +``` + +2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants : + - Admin : `view` + `manage` + `accounting.view` + `accounting.manage` + `archive` + - Bureau : `view` + `manage` + - Compta : `view` + `accounting.view` + `accounting.manage` + - Commerciale : `view` + `manage` + `sites.bypass_scope` + - Bureau / Compta : + `sites.bypass_scope` (voient tous les sites) + - Usine : `view` **sans** `sites.bypass_scope` → cloisonné à son site (§ 2.13). Persona avec un `currentSite` positionné pour tester le filtre. + +3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas. + +> ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé). + +### 5.4 Vérification front + +- `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`technique.providers.accounting.view`). +- Bouton « Archiver » visible si `technique.providers.archive` (Admin seul). + +## 6. Audit & dates + +- `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic`). +- Audit M2M automatique sur `provider.categories` et `provider.sites`. +- Timestampable + Blamable : pattern Shared standard (§ 2.8). +- Libellés i18n `audit.entity.technique_*` (§ 2.7). + +## 7. Règles de gestion (RG) + +> Les RG-3.03 → RG-3.08 reprennent le docx source. RG-3.01 / RG-3.02 sont **supprimées** (refonte-contact). Les RG-3.09 → RG-3.16 sont des **précisions back** (miroir M2) explicitement marquées. + +### Formulaire principal + +- ~~**RG-3.01**~~ _(SUPPRIMÉE — refonte-contact, 11/06)_ : le contact principal inline (Nom OU Prénom) est retiré du formulaire principal. Garantie « au moins un contact nommé » portée par **RG-3.04** + **RG-3.12** sur `ProviderContact`. +- ~~**RG-3.02**~~ _(SUPPRIMÉE du formulaire principal — refonte-contact)_ : plus de téléphones inline sur le formulaire principal. Le « maximum 2 téléphones » reste applicable aux blocs `ProviderContact` (`phonePrimary` + `phoneSecondary`). +- **RG-3.03** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur le **formulaire principal** pour valider la création. `Assert\Count(min: 1)` sur `provider.sites` (M2M `provider_site`). **Spécificité M3** (le fournisseur n'avait pas de site sur le formulaire principal). **Écriture cloisonnée (§ 2.13)** : un user sans `sites.bypass_scope` ne peut choisir que des sites de ses `user_site` (sinon 422). + +### Onglet Contact + +- **RG-3.04** : Un bloc Contact est valide dès qu'**au moins 1 champ** est rempli (Nom, Prénom, Fonction, Téléphone ou Email). CHECK BDD `chk_provider_contact_name` (garde-fou minimal). Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a aucun champ rempli. + +### Onglet Adresse + +- **RG-3.05** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur **chaque adresse**. `Assert\Count(min: 1)` sur `providerAddress.sites` (M2M `provider_address_site`). +- **RG-3.06** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict de cohérence CP/Ville. + +### Onglet Comptabilité + +- **RG-3.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `ProviderProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422. +- **RG-3.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'` : + - `paymentType = LCR` ET `provider.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». + - DELETE du dernier RIB d'un prestataire en LCR → 409. + - Autres types : RIBs optionnels (0..n). + +### Précisions back (miroir M2) + +- **RG-3.09** _(précision back)_ : les `Category` posées sur `provider.categories` ET sur `provider_address.categories` doivent être de **type `PRESTATAIRE`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."`). Front : multi-selects alimentés par `GET /api/categories?typeCode=PRESTATAIRE`. +- **RG-3.10** _(précision back)_ : `companyName` unique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partiel `uq_provider_company_name_active`). Doublon → 409 « Un prestataire nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (§ 2.6). +- **RG-3.11** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `ProviderContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front. +- **RG-3.12** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-3.04). Pas de test back. +- **RG-3.13** _(archivage)_ : PATCH `{ "isArchived": true }` exige `technique.providers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête. +- **RG-3.14** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre prestataire actif a pris le nom) → 409. +- **RG-3.15** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes alors que l'user n'a pas toutes les permissions → **403 sur tout le payload** (pas de filtrage silencieux). Le front ne doit jamais envoyer de champ hors-permission. +- **RG-3.16** _(liste / tri)_ : `GET /api/providers` exclut par défaut archivés (`is_archived = TRUE`) + soft-deletés (`deleted_at IS NOT NULL`). `?includeArchived=true` inclut les archivés (pas les soft-deletés). Tri par défaut `companyName ASC`. +- **RG-3.17** _(cloisonnement par site — § 2.13)_ : un user **sans** `sites.bypass_scope` ne voit (liste + détail) que les prestataires dont `provider.sites` contient son `currentSite`. Liste : filtrée avant pagination (`totalItems` = périmètre user). Détail hors périmètre → **404**. Users `bypass_scope` (Admin auto) → tous sites. Cloisonnement **piloté par l'utilisateur, pas par le rôle**. + +## 8. Tests à automatiser + +### 8.1 Cas à couvrir (back — PHPUnit) + +- [ ] **RG-3.03** : POST prestataire sans site → 422 ; avec ≥ 1 site → 201 +- [ ] **RG-3.04** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200 +- [ ] **RG-3.05** : POST adresse sans aucun site → 422 +- [ ] **RG-3.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict) +- [ ] **RG-3.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200 +- [ ] **RG-3.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409 +- [ ] **RG-3.09** : POST `categories` avec une `Category` de type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address) +- [ ] **RG-3.10** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201 +- [ ] **RG-3.11** : POST `companyName="maintenance pro"` → persiste `"MAINTENANCE PRO"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `ProviderContact` +- [ ] **RG-3.13/14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409 +- [ ] **RG-3.15** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict) +- [ ] **RG-3.16** : GET liste sans flag → exclut archivés ; `?includeArchived=true` → inclut ; tri `companyName ASC` +- [ ] **RBAC** : Bureau / Commerciale / Compta / Usine sur chaque permission (matrice § 2.9) — 200/403 selon le verbe +- [ ] **🔴 Cloisonnement par site (RG-3.17 / § 2.13)** : user **sans** `bypass_scope`, `currentSite = 86` → la liste ne contient QUE les prestataires rattachés au site 86 (assertion sur `member` + `totalItems`) ; GET détail d'un prestataire site 17 → **404** ; user `bypass_scope` (admin) → voit tous les sites ; **écriture cloisonnée** : POST/PATCH par un user non-bypass avec un site hors de ses `user_site` (formulaire principal OU adresse) → 422 ; avec uniquement ses propres sites → 201/200 +- [ ] **Compta** : GET prestataire retourne les champs accounting ; PATCH accounting → 200 ; PATCH contacts/adresses → 403 ; POST création → 403 +- [ ] **Commerciale** : GET prestataire **sans** les champs accounting ; onglet Comptabilité masqué +- [ ] **🔴 Gating RIB (bug #4 M1)** : GET détail en tant que Commerciale → la clé `ribs` est **ABSENTE** (assertion sur le corps JSON) +- [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose bien la clé `isArchived` dans le JSON réel +- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (relation directe) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) ; `addresses[].sites[]` au détail +- [ ] **Filtre typeCode** : `GET /api/categories?typeCode=PRESTATAIRE` ne renvoie QUE les catégories de type PRESTATAIRE +- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/providers` avec N prestataires, nombre de requêtes SQL constant +- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Provider'`, `changes` correct ; iban/bic présents dans le diff ; M2M `sites`/`categories` tracés +- [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout +- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_provider_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert) +- [ ] **i18n audit** : `audit.entity.technique_provider`… présents (`AuditableEntitiesHaveI18nLabelTest` vert) + +### 8.2 Cas à couvrir (front — Vitest) + +- [ ] `usePaginatedList({url:'/providers'})` : exclusion archivés par défaut, envelope Hydra +- [ ] `useProviderForm()` : workflow par onglet (validation incrémentale, PATCH partiel) — **sans onglet Information** +- [ ] `useAddressAutocomplete()` : réutilisation M1/M2 (nominal + dégradé) — pas de nouveau test si déjà couvert +- [ ] Sélecteur de site formulaire principal (RG-3.03) : ≥ 1 requis +- [ ] `` : `` + « + Ajouter » → `/providers/new` +- [ ] Permissions : Compta accède à `/providers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité +- [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs) + +### 8.3 Tests E2E + +**Non prévus au M3** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `technique.providers.*` — cf. § 5.3. + +### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec) + +`ProviderFixtures` idempotent couvrant tous les cas des RG : +- Catégories de type PRESTATAIRE seedées (au moins « Maintenance industrielle », « Nettoyage », « Transport »). +- ≥ 1 prestataire **complet** (≥ 1 site sur le formulaire principal, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB). +- 1 prestataire **en LCR avec RIB** (RG-3.08) et 1 **en VIREMENT avec banque** (RG-3.07). +- 1 prestataire **archivé** (vérifier exclusion liste + restauration). +- Réutiliser les comptes de rôles démo (`bureau`, `compta`, `commerciale`, `usine`, `admin`). + +> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset`). Le `CategoryType PRESTATAIRE` est seedé **en migration ET en fixture**. + +### 8.5 Checklist RETEX (à cocher avant « spec prête ») + +- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) +- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** +- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front) +- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15) +- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés +- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) +- [x] Seed/fixtures démo planifiés (§ 8.4) +- [x] **Décisions tranchées (Matthieu, 11/06)** : module `Technique` (§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user via `sites.bypass_scope` (§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅ + +## 9. Hors-périmètre (HP) + +- **HP-M4-2** : **Remontée des référentiels comptables dans `Shared`** (ou module neutre) si isolation stricte souhaitée (cf. § 2.1). _NB : décision M3 = consommation ORM partagée, comme `Supplier` (validée Matthieu, 11/06)._ +- _**(ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3)**_ : le **cloisonnement par site** (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par `sites.bypass_scope`. +- **HP-M4-3** : **DELETE / soft delete d'un prestataire** (colonne `deleted_at` préparée, non exposée au M3). +- **HP-M4-4** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés, seed seulement. +- **HP-M4-5** : **CRUD admin de `CategoryType`** (le M3 seed seulement le type PRESTATAIRE). +- **HP-M4-6** : **Onglet Rapports** (front placeholder « À venir » ; aucun modèle ni API back). +- **HP-M4-7** : **Onglet Échanges** (placeholder « À venir »). +- **HP-M4-8** : **Validation IBAN/BIC stricte** (au M3, `Assert\Iban` / `Assert\Bic` standard sur `ProviderRib`). +- **HP-M4-9** : **Validation SIREN stricte** (Luhn) — au M3, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`. +- **HP-M4-10** : **Référencement entrant** (modules futurs ajoutant une FK `provider_id` : interventions, maintenance, etc.). +- **HP-M4-11** : **Export CSV** (XLSX uniquement au M3). +- **HP-M4-12** : **Liaison Prestataire ↔ Fournisseur / Client** (un même tiers multi-rôles). Au M3, entités strictement séparées. + +## 10. Liens & dépendances + +### Liens + +- Spec front : [`./spec-front.md`](./spec-front.md) +- Spec M2 fournisseurs (pattern de référence direct) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md) +- Spec M1 clients : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md) +- RETEX sérialisation : [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) +- Doc audit-log : [`../../audit-log.md`](../../audit-log.md) +- Site-aware (périmètre Usine) : [`../../modules/site-aware.md`](../../modules/site-aware.md) +- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` +- Trace fonctionnelle : `M3-reportoire-prestataires.docx` (V0.2) / `M3-reportoire-prestataires-V01.pdf` (V0.1, obsolète) + +### Dépendances amont (déjà en place dans Starseed) + +- Module `Commercial` : référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**, relation ORM) +- Module `Catalog` (M0) : `Category` + `CategoryType` + **filtre `?typeCode=`** (créé au M2) (+ seed type PRESTATAIRE au M3) +- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `provider_site` + `provider_address_site` +- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT +- `Shared` : `TimestampableBlamableTrait` + `Subscriber` +- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export) + +### Specs futures qui dépendent du M3 + +- **M-Interventions / Maintenance** : FK `provider_id`. + +--- + +## 📦 Tickets Lesstime (à découper) + +**TaskGroup Lesstime** : à créer — `M3 — Répertoire prestataires` (projet `ERP / Starseed`, projectId=6). + +Ordre indicatif (back avant front, migration en tête) : +0. **Module `Technique` + Taxonomie PRESTATAIRE** — créer `TechniqueModule` (ID/LABEL/REQUIRED/permissions) + activer dans `config/modules.php` + layer front `modules/technique/` ; seed `CategoryType PRESTATAIRE` (migration `ON CONFLICT` + fixture idempotente) + catégories prestataires ; **vérifier** que le filtre `?typeCode=PRESTATAIRE` fonctionne. Prérequis du multi-select Catégorie. +1. **Migration BDD M3** (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN) +2. **Entités + Repositories** (Provider, ProviderContact, ProviderAddress, ProviderRib) + **hydratation liste** (categories, sites — § 2.12) +3. **Provider + Processor** (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + **filtre de cloisonnement par site** (§ 2.13 / RG-3.17 : `ProviderSiteScopeExtension` réutilisant `CurrentSiteProvider` + `sites.bypass_scope` ; liste filtrée, détail 404 hors périmètre) +4. **Sous-ressources** (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor) +5. **Validators** (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03) +6. **Export XLSX** (ProviderExportController, priority:1) +7. **RBAC** : `TechniqueModule::permissions()` + sync 3 sources + tests personas +8. **Tests PHPUnit** : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis) +9. **Front : page Répertoire** (`/providers`) + `usePaginatedList` +10. **Front : page Création** (`/providers/new`) + `useProviderForm` (sans onglet Information) +11. **Front : page Consultation** (`/providers/{id}`) + onglets placeholder « À venir » (Rapports / Échanges) +12. **Front : page Modification** (`/providers/{id}/edit`) +13. **i18n + Sidebar** (section `sidebar.technique.section` + `sidebar.technique.providers` + permission, traductions, libellés audit) + +### Actions manuelles dans Lesstime (Matthieu) + +1. Créer le TaskGroup `M3 — Répertoire prestataires` (projet ERP / Starseed, projectId=6). +2. Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles. +3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel. + +### ✅ Décisions tranchées (Matthieu, 11/06/2026) + +1. **Module `Technique`** (§ 2.1) — nouveau module back + section sidebar « Technique ». ✅ +2. **Référentiels comptables** — « comme supplier » : consommation ORM partagée (pas de remontée dans `Shared`). ✅ +3. **Cloisonnement par site** (§ 2.13 / RG-3.17) — visibilité pilotée par l'**utilisateur** (son `currentSite`), automatique côté back ; bypass multi-sites via `sites.bypass_scope` (Admin auto + Bureau/Compta/Commerciale ; **Usine cloisonnée**). Indépendant du rôle. ✅ +4. **Unicité = nom seul** (§ 2.6). ✅ + +5. **Écriture cloisonnée** (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que **les sites dont il dispose** (`user_site`), formulaire principal ET adresses ; site hors périmètre → 422. ✅ + +### ⚠️ Point de raffinement à confirmer (non bloquant) + +- **Attribution `sites.bypass_scope`** : confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non). diff --git a/docs/specs/M3-prestataires/spec-front.md b/docs/specs/M3-prestataires/spec-front.md new file mode 100644 index 0000000..bc6720b --- /dev/null +++ b/docs/specs/M3-prestataires/spec-front.md @@ -0,0 +1,339 @@ +--- +# === IDENTITÉ === +module: M3 +nom: "Répertoire prestataires" +ecran: repertoire-prestataires +owner_spec: Matthieu +backup_spec: Tristan +version: V0.2 +date_redaction: 2026-06-11 +# Historique : +# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026). +# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal +# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via +# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »). +# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence. +# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal). + +# === LIENS === +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev" +regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17] +roles: [Admin, Bureau, Compta, Commerciale, Usine] +lien_spec_back: ./spec-back.md + +# === VALIDATION CLIENT === +client_validation_1: + statut: validee + date: 2026-05-22 + version: V0 + valide_par: "Matthieu (CP MALIO)" +client_validation_2: + statut: validee + date: 2026-06-01 + version: V0.1 + valide_par: "Matthieu (CP MALIO)" +client_validation_3: + statut: a_valider + date: 2026-06-04 + version: V0.2 + resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal." + trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6) +lesstime_project_id: 6 +statut_global: en_dev +--- + +# Module 3 — Répertoire prestataires (V0.2 front) + +> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md). + +> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact. + +> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée). + +## But + +Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**. + +## Accès + +- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`). +- **Rôles autorisés** (tableau « Rôles & permissions » du docx) : + +| Rôle | Consultation | Création / Modification | Archivage | +|---|---|---|---| +| **Admin** | ✅ Tout | ✅ Tout | ✅ | +| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ | +| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ | +| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ | +| **Usine** | ✅ Son site uniquement | — | ❌ | + +> **Notes** : +> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**. +> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front. + +## Navigation + +Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ». + +- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié). +- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée). +- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**. +- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous). +- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md). + +### Panneau de filtres (bouton « Filtrer ») + +Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) : + +| Filtre | Composant | Query param back | +|---|---|---| +| **Recherche** (nom entreprise / contact / email) | `` | `?search=` | +| **Catégorie** | `` (multi, type PRESTATAIRE) | `?categoryCode=` | +| **Site** | `` (86 / 17 / 82) | `?siteId=` | +| **Inclure les archivés** | `` | `?includeArchived=true` | + +- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`. +- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). + +## Datatable du Répertoire + +Composant : `` branché sur `usePaginatedList({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) : + +| Colonne | Source | Tri | +|---|---|---| +| **Nom** | `provider.companyName` | ASC par défaut | +| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non | +| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non | +| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui | + +> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md). +> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut. + +## Écran « Ajouter un prestataire » + +Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation). + +**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau. + +**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »). + +> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs). + +> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec. + +### Formulaire principal (pré-onglets) + +1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly. + +| Champ | Type composant | Obligatoire | Règle | +|---|---|---|---| +| **Nom du prestataire (Entreprise)** | `` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) | +| **Catégorie** | `` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. | +| **Sélecteur de site** | `` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). | + +**Action** : « Valider » (``) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ». + +### Onglet « Contact » + +Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** + +**Bloc Contact** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Nom** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) | +| **Prénom** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) | +| **Fonction** | `` | Non | — | +| **Téléphone** (x1, +1 possible, **max 2**) | `` | Non | RG-3.11 (format) ; max 2 téléphones par contact | +| **Email** | `` type email | Non | RG-3.11 (lowercase) | + +**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide. + +**Actions** : +- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04). +- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc. +- « Valider » → PATCH `/api/providers/{id}/contacts`. + +### Onglet « Adresse » + +Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts. + +**Bloc Adresse** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Sélecteur de site** | `` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). | +| **Adresse** | `` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN | +| **Adresse complémentaire** | `` | Non | — | +| **Code postal** | `` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) | +| **Ville** | `` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select | +| **Pays** | `` (préremplie « France ») | Oui | — | +| **Catégories** | `` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) | +| **Contact** | `` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact | + +> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts). + +**Actions** : +- « + Nouvelle Adresse » : ajoute un bloc identique au premier. +- « Supprimer » (icône) : modal de confirmation puis suppression. +- « Valider » → PATCH `/api/providers/{id}/addresses`. + +### Onglet « Comptabilité » + +⚠ **Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global). + +**Champs comptables** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **SIREN** | `` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) | +| **Numéro de compte** | `` | Oui | — | +| **Mode de TVA** | `` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) | +| **N° de TVA** | `` | Oui | — | +| **Délai de règlement** | `` | Oui | Liste depuis `/api/payment_delays` | +| **Type de règlement** | `` | Oui | Liste depuis `/api/payment_types` | +| **Banque** | `` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). | + +**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Libellé** | `` | Oui (si LCR) | RG-3.08 | +| **BIC** | `` | Oui (si LCR) | RG-3.08 | +| **IBAN** | `` | Oui (si LCR) | RG-3.08 | + +**Actions** : +- « + RIB » : ajoute un bloc. +- « Supprimer » (icône) : modal de confirmation. +- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs. + +## Écran « Consultation prestataire » + +Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs. + +- **Flèche retour** (gauche) → revient au Répertoire. +- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification. +- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`. + +> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. + +### Onglets affichés en consultation + +`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission. + +- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email). +- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact). +- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07). + +## Écran « Modification prestataire » + +Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf : +- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis). +- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire. +- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel). +- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression). +- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement). + +## Composants UI à utiliser (`@malio/layer-ui`) + +- **Datatable** : `` (+ `usePaginatedList`) +- **Input texte** : `` +- **Select simple** : `` (Pays, Ville, référentiels comptables) +- **Select multi (cases à cocher)** : `` (Catégorie, Sites, Contacts rattachés) +- **Bouton** : ``, `` +- **Toasts** : standards via `useApi()` +- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire) + +**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) : +- Modal de confirmation : `` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2). + +## Composables & appels API + +- `usePaginatedList({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). +- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)). +- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`. +- `useAddressAutocomplete()` — **réutilisé du M1/M2** (BAN), pas de réécriture. +- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver. +- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4). +- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`. + +## Règles de formatage et normalisation + +Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) : + +| Champ | Normalisation serveur | Affichage front | +|---|---|---| +| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE | +| Nom + Prénom contact | Capitalize | identique | +| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) | +| Email | lowercase intégral | identique | + +> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche. + +## API adresse postale + +Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) : +- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select). +- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions. +- Cas dégradé (timeout / offline) : Ville en `` libre + toast d'avertissement. + +## Différences notables avec le M2 (fournisseurs) + +| Zone | M2 fournisseurs | M3 prestataires | +|---|---|---| +| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) | +| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) | +| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** | +| Bennes / Prestation de triage (adresse) | Présents | **Absents** | +| Onglet Transport | Placeholder | **Absent** | +| Onglet Statistiques | Placeholder | **Absent** | +| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement | +| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** | +| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) | +| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 | + +## Points résolus côté back + +| # | Zone d'ombre | Résolution (cf. `spec-back.md`) | +|---|---|---| +| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) | +| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) | +| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas | +| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » | +| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) | +| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP | +| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques | +| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée | +| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) | +| 10 | Format export | XLSX uniquement (CSV = HP) | +| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) | + +--- + +## 📦 Tickets Lesstime + +**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131` → `ERP-146`, statut « Prêt à dev », assignés à **Tristan**. + +| # | Ticket | Réf | Tag | +|---|---|---|---| +| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend | +| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend | +| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend | +| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend | +| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend | +| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend | +| 1.7 | Export XLSX des prestataires | ERP-137 | Backend | +| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend | +| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend | +| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend | +| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend | +| 1.12 | Onglet Contact | ERP-142 | Frontend | +| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend | +| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend | +| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend | +| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend | + +> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper). diff --git a/docs/specs/_RETEX-M1-pour-M2.md b/docs/specs/_RETEX-M1-pour-M2.md new file mode 100644 index 0000000..3d62c81 --- /dev/null +++ b/docs/specs/_RETEX-M1-pour-M2.md @@ -0,0 +1,80 @@ +# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs) + +> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1. +> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier. +> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction. + +--- + +## 0. TL;DR (les 3 erreurs à ne jamais refaire) + +1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler. +2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat. +3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté. + +--- + +## 1. Contrat de sérialisation : les 3 maillons obligatoires + +Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout. + +| Maillon | Question | Exemple M1 raté | +|---|---|---| +| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé | +| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` | +| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code` ∈ `category:read`, absent du contexte client → pas de `code` | + +**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse). + +## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER + +Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) : + +- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups([':item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item). +- **GetCollection sous-ressource** : `//{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST). + +❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit. + +## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front + +Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** : + +> Créer un enregistrement de test, appeler `GET /api/` (liste) ET `GET /api//{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé. + +## 4. La spec décrit le RÉEL, pas l'intention + +- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »). +- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier. + +## 5. Réutiliser les acquis M1 (ne pas réinventer) + +- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module). +- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation. +- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme. +- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec. +- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet. + +## 6. Règles ABSOLUES transverses à rappeler dans la spec M2 + +- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés. +- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable. +- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi. +- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret). +- **`declare(strict_types=1);`** partout ; commentaires FR, code EN. +- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL. +- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`. +- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct. + +## 7. Fixtures & seed dès le départ + +M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage. + +## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête) + +- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées). +- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only). +- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back. +- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés. +- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés. +- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation). +- [ ] Seed/fixtures démo planifiés. diff --git a/frontend/modules/technique/nuxt.config.ts b/frontend/modules/technique/nuxt.config.ts new file mode 100644 index 0000000..268da7f --- /dev/null +++ b/frontend/modules/technique/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/migrations/Version20260612080000.php b/migrations/Version20260612080000.php new file mode 100644 index 0000000..bf4d7cd --- /dev/null +++ b/migrations/Version20260612080000.php @@ -0,0 +1,121 @@ + pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) : + * la migration ne fait que des INSERT de donnees de reference. + * + * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : + * avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN + * alphabetique -> une migration `App\Module\...` passerait avant les + * `DoctrineMigrations\...` sur base vide, donc avant la creation des tables + * `category` / `category_type` / `category_category_type`. Le namespace racine + * garantit l'ordre par timestamp. + * + * Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type, + * `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne + * de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la + * table `category` est vide (aucune fixture metier). En dev/test, le purger + * Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent + * le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE). + */ +final class Version20260612080000 extends AbstractMigration +{ + /** + * Categories de demonstration du type PRESTATAIRE : nom => code stable. Le + * code est la cle metier (slug MAJUSCULE du nom, miroir du + * CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code, + * partage avec les codes CLIENT / FOURNISSEUR — aucune collision ici). Le nom + * est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les + * libelles ci-dessous n'entrent en collision avec aucune categorie seedee. + */ + private const array PROVIDER_CATEGORIES = [ + 'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE', + 'Nettoyage' => 'NETTOYAGE', + 'Transport' => 'TRANSPORT', + ]; + + public function getDescription(): string + { + return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).'; + } + + public function up(Schema $schema): void + { + // 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code). + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire') + ON CONFLICT (code) DO NOTHING + SQL); + + foreach (self::PROVIDER_CATEGORIES as $name => $code) { + // 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les + // actifs). created_at/updated_at NOT NULL -> NOW() ; le blame + // reste null (seed hors contexte HTTP, libelle « Systeme » cote front). + $this->addSql(<<<'SQL' + INSERT INTO category (name, code, created_at, updated_at) + SELECT :name, :code, NOW(), NOW() + WHERE NOT EXISTS ( + SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL + ) + SQL, ['name' => $name, 'code' => $code]); + + // 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant). + $this->addSql(<<<'SQL' + INSERT INTO category_category_type (category_id, category_type_id) + SELECT c.id, ct.id + FROM category c + CROSS JOIN category_type ct + WHERE c.code = :code AND c.deleted_at IS NULL + AND ct.code = 'PRESTATAIRE' + AND NOT EXISTS ( + SELECT 1 FROM category_category_type cct + WHERE cct.category_id = c.id AND cct.category_type_id = ct.id + ) + SQL, ['code' => $code]); + } + } + + public function down(Schema $schema): void + { + // Best-effort : on retire d'abord les categories seedees (par code) — la FK + // category_category_type est ON DELETE CASCADE cote category, donc les + // lignes de jonction partent avec —, puis le type s'il n'est plus reference. + $this->addSql( + 'DELETE FROM category WHERE code IN (:codes) ' + ."AND id IN (SELECT category_id FROM category_category_type cct " + ."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')", + ['codes' => array_values(self::PROVIDER_CATEGORIES)], + ['codes' => ArrayParameterType::STRING], + ); + + $this->addSql(<<<'SQL' + DELETE FROM category_type + WHERE code = 'PRESTATAIRE' + AND NOT EXISTS ( + SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id + ) + SQL); + } +} diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index 760cf89..b150a45 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * Fixtures dev/test du module Catalog : categories de demonstration rattachees * a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte * taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs - * (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable. + * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories + * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque + * categorie porte un `code` stable. * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). @@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface 'Grossiste' => 'GROSSISTE', 'Importateur' => 'IMPORTATEUR', ], + 'PRESTATAIRE' => [ + 'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE', + 'Nettoyage' => 'NETTOYAGE', + 'Transport' => 'TRANSPORT', + ], ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index b329414..8d5c518 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager; * taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de * la migration Version20260605120000. * + * M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »), + * taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage, + * Transport). Mirroir de la migration Version20260612080000. + * * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * entite managee par l ORM, donc le purger Doctrine la vide avant chaque * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la @@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture { /** * Source unique des types : code technique => libelle FR. Doit rester aligne - * sur le seed des migrations Version20260602100000 (CLIENT) et - * Version20260605120000 (FOURNISSEUR). + * sur le seed des migrations Version20260602100000 (CLIENT), + * Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE). */ private const TYPES = [ 'CLIENT' => 'Client', 'FOURNISSEUR' => 'Fournisseur', + 'PRESTATAIRE' => 'Prestataire', ]; public function __construct( diff --git a/src/Module/Technique/TechniqueModule.php b/src/Module/Technique/TechniqueModule.php new file mode 100644 index 0000000..a5653cc --- /dev/null +++ b/src/Module/Technique/TechniqueModule.php @@ -0,0 +1,58 @@ + + */ + public static function permissions(): array + { + return [ + ['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'], + ['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'], + ['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'], + ['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'], + ['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'], + ]; + } +} diff --git a/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php new file mode 100644 index 0000000..b1a9b05 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php @@ -0,0 +1,107 @@ +getOrCreatePrestataireType(); + foreach (self::PROVIDER_CATEGORIES as $name) { + $this->createCategory($name, $providerType); + } + + // Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter. + $noiseType = $this->createCategoryType('TEST_FOURNISSEUR', 'Test Fournisseur'); + $this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false'); + self::assertSame(200, $response->getStatusCode()); + + $members = $response->toArray()['member']; + $names = array_map(static fn (array $m): string => $m['name'], $members); + sort($names); + + $expected = self::PROVIDER_CATEGORIES; + sort($expected); + self::assertSame( + $expected, + $names, + 'Le filtre ?typeCode=PRESTATAIRE doit ne renvoyer QUE les categories du type PRESTATAIRE.', + ); + + // Chaque categorie remontee doit PORTER le type PRESTATAIRE. + foreach ($members as $member) { + self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code')); + } + } + + public function testTypeCodePrestataireKeepsHydraPagination(): void + { + $providerType = $this->getOrCreatePrestataireType(); + $this->createCategory('Maintenance industrielle', $providerType); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE'); + self::assertSame(200, $response->getStatusCode()); + + $data = $response->toArray(); + self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.'); + self::assertArrayHasKey('member', $data); + + foreach ($data['member'] as $member) { + self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code')); + } + } + + /** + * Recupere le type PRESTATAIRE reel, ou le cree s'il est absent. Le code + * `PRESTATAIRE` est seede par CategoryTypeFixtures (present en debut de suite), + * mais le cleanup purge tous les `category_type` entre les tests : selon + * l'ordre d'execution, le type peut donc exister ou non. Le get-or-create rend + * le test robuste sans dependre du seed ni le dupliquer. + */ + private function getOrCreatePrestataireType(): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']); + + if ($existing instanceof CategoryType) { + return $existing; + } + + return $this->createCategoryType('PRESTATAIRE', 'Prestataire'); + } +} diff --git a/tests/Module/Technique/TechniqueModuleTest.php b/tests/Module/Technique/TechniqueModuleTest.php new file mode 100644 index 0000000..bf3286d --- /dev/null +++ b/tests/Module/Technique/TechniqueModuleTest.php @@ -0,0 +1,59 @@ +