From d6790dd37d7e31c28f9760e821f455e0a29bfe97 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:14:51 +0000 Subject: [PATCH] feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94) (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERP-94 (etape front 7/7 du M2). **Stack sur #97** (base = `feature/ERP-97-suppliers-i18n-sidebar`, elle-meme sur #93) pour un diff isole. A recibler sur `develop` une fois #93 (MR #81) et #97 (MR #82) mergees. Page « Ajouter un fournisseur » — **replique a l'identique le fonctionnement de l'ecran Client** (workflow inline par onglets, blocs reutilisables, validation 422 inline ERP-101), avec les specificites M2. ## Architecture (miroir Client) - Workflow par onglets **inline dans `suppliers/new.vue`** (comme `clients/new.vue` — il n'existe pas de `useClientForm` monolithique). Helpers paralleles : `useSupplierReferentials`, `useSupplierFormErrors`, `supplierFormRules`, `supplierEdit` (payloads), `types/supplierForm`. - Blocs `SupplierContactBlock` / `SupplierAddressBlock` (miroir des blocs Client). - POST `/suppliers` puis PATCH partiels par onglet (mode strict, groupes de serialisation). Sous-ressources : `/suppliers/{id}/contacts|addresses|ribs`. - Validation ERP-101 : 422 `violations[].propertyPath` mappees inline par champ (`useFormErrors` / `mapViolationsToRecord`), `{ toast: false }`, bouton Valider toujours actif. ## Specificites M2 (vs M1) - Formulaire principal **sans contact inline** (ERP-106) : Entreprise + Categorie (type FOURNISSEUR, `?typeCode=FOURNISSEUR`). - Adresse : **radio exclusif** Prospect/Depart/Rendu (`addressType` enum, RG-2.09), champs **Bennes** (stepper) + **Prestation de triage**, **pas d'email de facturation**. - Information : champ **Volume previsionnel** (8e champ). - Compta (Admin+Compta) : banque si VIREMENT (RG-2.07), RIB si LCR (RG-2.08) ; RIB sous-ressource gardee par `accounting.manage`. ## Tests (mirroir strategie Client) - `make nuxt-test` : 338 passed (specs ajoutees : supplierFormRules, supplierEdit, useSupplierReferentials, SupplierContactBlock, SupplierAddressBlock). - ESLint propre ; `nuxi typecheck` (lance en container) : **0 erreur**. - Golden path navigateur valide end-to-end : POST /suppliers OK, companyName normalise UPPERCASE (RG-2.12), gating des onglets (Information actif, Contacts deverrouille). ## Note de revue ~30 `WARN Duplicated imports` au typecheck : les helpers Supplier exportent les memes noms generiques que leurs equivalents Client (`buildMainPayload`, `omitEmptyRequired`, `RefOption`...), tous deux auto-importes par Nuxt. **Sans impact runtime** : tous les consommateurs utilisent des imports explicites (qui priment). Consequence directe du miroir 1:1 ; une factorisation des generiques dans `shared/` pourrait etre un suivi. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/83 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 75 ++ .../components/SupplierAddressBlock.vue | 314 +++++++ .../components/SupplierContactBlock.vue | 104 +++ .../__tests__/SupplierAddressBlock.spec.ts | 178 ++++ .../__tests__/SupplierContactBlock.spec.ts | 56 ++ .../__tests__/useSupplierReferentials.spec.ts | 63 ++ .../composables/useSupplierFormErrors.ts | 88 ++ .../composables/useSupplierReferentials.ts | 118 +++ .../commercial/pages/suppliers/new.vue | 864 ++++++++++++++++++ .../modules/commercial/types/supplierForm.ts | 109 +++ .../utils/__tests__/supplierEdit.spec.ts | 115 +++ .../utils/__tests__/supplierFormRules.spec.ts | 190 ++++ .../modules/commercial/utils/supplierEdit.ts | 142 +++ .../commercial/utils/supplierFormRules.ts | 219 +++++ ...upplierAccountingCompletenessValidator.php | 78 ++ ...pplierInformationCompletenessValidator.php | 82 -- .../Commercial/Domain/Entity/Supplier.php | 9 +- .../Domain/Entity/SupplierAddress.php | 4 +- .../State/Processor/SupplierProcessor.php | 76 +- .../Api/AbstractSupplierApiTestCase.php | 3 +- .../Api/SupplierAccountingApiTest.php | 59 +- .../Commercial/Api/SupplierRBACMatrixTest.php | 62 +- .../Api/SupplierSubResourceApiTest.php | 14 +- .../Domain/Entity/SupplierValidationTest.php | 8 +- ...erInformationCompletenessValidatorTest.php | 129 --- .../Commercial/Unit/SupplierProcessorTest.php | 244 ----- 26 files changed, 2837 insertions(+), 566 deletions(-) create mode 100644 frontend/modules/commercial/components/SupplierAddressBlock.vue create mode 100644 frontend/modules/commercial/components/SupplierContactBlock.vue create mode 100644 frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts create mode 100644 frontend/modules/commercial/components/__tests__/SupplierContactBlock.spec.ts create mode 100644 frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts create mode 100644 frontend/modules/commercial/composables/useSupplierFormErrors.ts create mode 100644 frontend/modules/commercial/composables/useSupplierReferentials.ts create mode 100644 frontend/modules/commercial/pages/suppliers/new.vue create mode 100644 frontend/modules/commercial/types/supplierForm.ts create mode 100644 frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts create mode 100644 frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts create mode 100644 frontend/modules/commercial/utils/supplierEdit.ts create mode 100644 frontend/modules/commercial/utils/supplierFormRules.ts create mode 100644 src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php delete mode 100644 src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php delete mode 100644 tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php delete mode 100644 tests/Module/Commercial/Unit/SupplierProcessorTest.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index b5c6298..d3ea681 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -116,6 +116,81 @@ "loading": "Chargement du fournisseur…", "notFound": "Fournisseur introuvable.", "save": "Valider" + }, + "form": { + "title": "Ajouter un fournisseur", + "back": "Précédent", + "submit": "Valider", + "duplicateCompany": "Un fournisseur portant ce nom de société existe déjà.", + "main": { + "companyName": "Nom du fournisseur (Entreprise)", + "categories": "Catégorie" + }, + "information": { + "description": "Description", + "competitors": "Concurrent", + "foundedAt": "Date de création", + "employeesCount": "Nombre de salariés", + "revenueAmount": "CA", + "profitAmount": "Résultat", + "directorName": "Dirigeant", + "volumeForecast": "Volume prévisionnel" + }, + "contact": { + "title": "Contact {n}", + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "remove": "Supprimer le contact", + "add": "Nouveau contact" + }, + "address": { + "title": "Adresse {n}", + "addressType": "Type d'adresse", + "addressTypeProspect": "Prospect", + "addressTypeDepart": "Départ", + "addressTypeRendu": "Rendu", + "categories": "Catégorie", + "country": "Pays", + "postalCode": "Code postal", + "city": "Ville", + "street": "Adresse", + "streetNotFound": "Adresse introuvable ? Saisissez-la directement.", + "streetComplement": "Adresse complémentaire", + "sites": "Sites", + "contacts": "Contact(s) rattaché(s)", + "bennes": "Benne(s)", + "triageProvider": "Prestation de triage", + "remove": "Supprimer l'adresse", + "add": "Nouvelle adresse", + "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." + }, + "accounting": { + "siren": "SIREN", + "accountNumber": "Numéro de compte", + "tvaMode": "Mode de TVA", + "nTva": "N° de TVA", + "paymentDelay": "Délai de règlement", + "paymentType": "Type de règlement", + "bank": "Banque", + "ribLabel": "Libellé", + "ribBic": "BIC", + "ribIban": "IBAN", + "addRib": "Ajouter un RIB", + "removeRib": "Supprimer le RIB" + }, + "confirmDelete": { + "title": "Confirmer la suppression", + "contact": "Supprimer ce contact ?", + "address": "Supprimer cette adresse ?", + "rib": "Supprimer ce RIB ?", + "cancel": "Annuler", + "confirm": "Confirmer" + } } }, "clients": { diff --git a/frontend/modules/commercial/components/SupplierAddressBlock.vue b/frontend/modules/commercial/components/SupplierAddressBlock.vue new file mode 100644 index 0000000..2c6eec6 --- /dev/null +++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue @@ -0,0 +1,314 @@ + + + diff --git a/frontend/modules/commercial/components/SupplierContactBlock.vue b/frontend/modules/commercial/components/SupplierContactBlock.vue new file mode 100644 index 0000000..13ef486 --- /dev/null +++ b/frontend/modules/commercial/components/SupplierContactBlock.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts new file mode 100644 index 0000000..a2983b8 --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyAddress } from '~/modules/commercial/types/supplierForm' +import SupplierAddressBlock from '../SupplierAddressBlock.vue' + +// Mocks controlables du composable BAN (hoisted). +const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({ + searchCityMock: vi.fn(), + searchAddressMock: vi.fn(), +})) +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: searchCityMock, + searchAddress: searchAddressMock, + }), +})) + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate. +const MalioInputAutocompleteStub = defineComponent({ + name: 'MalioInputAutocomplete', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + options: { type: Array as () => { value: string | number, label: string }[], default: () => [] }, + loading: { type: Boolean, default: false }, + minSearchLength: { type: Number, default: 0 }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + allowCreate: { type: Boolean, default: false }, + }, + emits: ['update:modelValue', 'search', 'select'], + setup(props) { + return () => h('div', { + 'data-testid': 'addr-autocomplete', + 'data-options': JSON.stringify(props.options.map(o => o.value)), + }) + }, +}) + +function mountBlock(overrides: Record = {}, errors?: Record) { + return mount(SupplierAddressBlock, { + props: { + modelValue: { ...emptyAddress(), ...overrides }, + title: 'Adresse 1', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioInputNumber: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => { + it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => { + const wrapper = mountBlock() + const addressTypeSelect = wrapper.findAll('malio-select-stub').find( + el => el.attributes('label') === 'commercial.suppliers.form.address.addressType', + ) + expect(addressTypeSelect).toBeDefined() + }) + + it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => { + const wrapper = mountBlock() + expect(wrapper.find('malio-input-number-stub').exists()).toBe(true) + expect(wrapper.find('malio-checkbox-stub').exists()).toBe(true) + }) + + it('ne rend aucun champ d\'email de facturation (difference M1)', () => { + const wrapper = mountBlock() + // Aucun MalioInputEmail dans le bloc adresse fournisseur. + expect(wrapper.find('malio-input-email-stub').exists()).toBe(false) + }) +}) + +describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => { + const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' }) + const addressTypeSelect = wrapper.findAll('malio-select-stub').find( + el => el.attributes('label') === 'commercial.suppliers.form.address.addressType', + ) + expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.') + }) + + it('affiche les erreurs serveur sur sites et categories', () => { + const wrapper = mountBlock({}, { + sites: 'Au moins un site est obligatoire.', + categories: 'Au moins une catégorie est obligatoire.', + }) + const checkboxes = wrapper.findAll('malio-select-checkbox-stub') + const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.sites') + const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.form.address.categories') + + expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') + expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.') + }) + + it('affiche l\'erreur serveur sur le code postal', () => { + const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' }) + const field = wrapper.findAll('malio-input-text-stub').find( + el => el.attributes('label') === 'commercial.suppliers.form.address.postalCode', + ) + expect(field?.attributes('error')).toBe('Code postal invalide.') + }) +}) + +describe('SupplierAddressBlock — autocompletion adresse (BAN) robuste', () => { + beforeEach(() => { + searchAddressMock.mockReset() + }) + + it('n\'appelle pas la BAN en deca de 3 caracteres', async () => { + const wrapper = mountBlock() + wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab') + await flushPromises() + expect(searchAddressMock).not.toHaveBeenCalled() + }) + + it('relance la recherche apres une erreur (pas de bascule definitive)', async () => { + searchAddressMock + .mockRejectedValueOnce(new Error('BAN indisponible')) + .mockResolvedValueOnce([ + { label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' }, + ]) + + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'boulevard du port') + await flushPromises() + auto.vm.$emit('search', 'boulevard du porte') + await flushPromises() + + expect(searchAddressMock).toHaveBeenCalledTimes(2) + expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true) + }) + + it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => { + searchAddressMock.mockRejectedValue(new Error('BAN indisponible')) + + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'rue de la paix') + await flushPromises() + auto.vm.$emit('search', 'rue de la paixx') + await flushPromises() + + expect(wrapper.emitted('degraded')).toHaveLength(1) + }) + + it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => { + const wrapper = mountBlock() + expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true) + }) + + it('inclut la rue courante dans les options meme sans recherche BAN', () => { + const wrapper = mountBlock({ street: '8 Boulevard du Port' }) + const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]') + expect(values).toContain('8 Boulevard du Port') + }) +}) diff --git a/frontend/modules/commercial/components/__tests__/SupplierContactBlock.spec.ts b/frontend/modules/commercial/components/__tests__/SupplierContactBlock.spec.ts new file mode 100644 index 0000000..acfce8c --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/SupplierContactBlock.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyContact } from '~/modules/commercial/types/supplierForm' +import SupplierContactBlock from '../SupplierContactBlock.vue' + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */ +function errorProbe(testid: string) { + return defineComponent({ + name: `Probe-${testid}`, + props: { + modelValue: { type: [String, Number, null], default: undefined }, + error: { type: String, default: '' }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + }, + setup(props) { + return () => h('div', { 'data-testid': testid, 'data-error': props.error }) + }, + }) +} + +function mountBlock(errors?: Record) { + return mount(SupplierContactBlock, { + props: { + modelValue: emptyContact(), + title: 'Contact 1', + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioInputPhone: true, + MalioInputText: errorProbe('contact-text'), + MalioInputEmail: errorProbe('contact-email'), + }, + }, + }) +} + +describe('SupplierContactBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche l\'erreur serveur sur le champ email via la prop errors', () => { + const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' }) + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('Adresse e-mail invalide.') + }) + + it('laisse les champs sans erreur quand errors est absent', () => { + const wrapper = mountBlock() + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('') + }) +}) diff --git a/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts new file mode 100644 index 0000000..c25eb73 --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels de chargement des referentiels et controler les reponses Hydra. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ get: mockGet })) + +const { useSupplierReferentials } = await import('../useSupplierReferentials') + +describe('useSupplierReferentials', () => { + beforeEach(() => { + mockGet.mockReset() + mockGet.mockResolvedValue({ member: [] }) + }) + + it('charge les categories filtrees sur le type FOURNISSEUR (RG-2.10)', async () => { + await useSupplierReferentials().loadCommon() + + expect(mockGet).toHaveBeenCalledWith( + '/categories', + expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }), + expect.objectContaining({ toast: false }), + ) + }) + + it('mappe les categories en options { value: IRI, label: name, code }', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/categories') { + return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'NEGOCIANT', name: 'Négociant' }] }) + } + return Promise.resolve({ member: [] }) + }) + + const refs = useSupplierReferentials() + await refs.loadCommon() + + expect(refs.categories.value).toEqual([{ value: '/api/categories/9', label: 'Négociant', code: 'NEGOCIANT' }]) + }) + + it('ne charge ni distributeurs ni courtiers (absents du modele fournisseur)', async () => { + await useSupplierReferentials().loadCommon() + + const urls = mockGet.mock.calls.map(c => c[0]) + expect(urls).not.toContain('/clients') + expect(urls).toEqual( + expect.arrayContaining(['/categories', '/sites', '/tva_modes', '/payment_delays', '/payment_types', '/banks']), + ) + }) + + it('reste resilient : un referentiel en echec n\'empeche pas les autres', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/categories') return Promise.reject(new Error('403')) + if (url === '/banks') return Promise.resolve({ member: [{ '@id': '/api/banks/1', code: 'SG', label: 'Société Générale' }] }) + return Promise.resolve({ member: [] }) + }) + + const refs = useSupplierReferentials() + await refs.loadCommon() + + expect(refs.categories.value).toEqual([]) + expect(refs.banks.value).toEqual([{ value: '/api/banks/1', label: 'Société Générale' }]) + }) +}) diff --git a/frontend/modules/commercial/composables/useSupplierFormErrors.ts b/frontend/modules/commercial/composables/useSupplierFormErrors.ts new file mode 100644 index 0000000..73e8fd8 --- /dev/null +++ b/frontend/modules/commercial/composables/useSupplierFormErrors.ts @@ -0,0 +1,88 @@ +/** + * Composable d'erreurs partage des ecrans fournisseur (creation + edition, M2 + * Commercial). Miroir de `useClientFormErrors` (M1) : + * - un `useFormErrors` par groupe scalaire (Principal / Information / + * Comptabilite) : violations 422 affichees inline sous chaque champ ; + * - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts / + * adresses / RIB), aligne sur l'index du `v-for`. + * + * `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe + * inline). Chaque page conserve ainsi son propre fallback dans le `catch`. + */ +import { ref, type Ref } from 'vue' +import { mapViolationsToRecord } from '~/shared/utils/api' + +export function useSupplierFormErrors() { + const mainErrors = useFormErrors() + const informationErrors = useFormErrors() + const accountingErrors = useFormErrors() + const contactErrors = ref[]>([]) + const addressErrors = ref[]>([]) + const ribErrors = ref[]>([]) + + /** + * Mappe l'erreur d'une ligne de collection sur le tableau cible (par index). + * 422 avec violations exploitables → erreurs inline sous les champs de la + * ligne + retourne true. Sinon → ne touche pas la cible et retourne false. + */ + function mapRowError( + error: unknown, + target: Ref[]>, + index: number, + ): boolean { + const response = (error as { response?: { status?: number, _data?: unknown } })?.response + const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {} + if (Object.keys(mapped).length > 0) { + target.value[index] = mapped + return true + } + return false + } + + /** + * Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en + * collectant les erreurs par index : on n'arrete PAS au premier bloc en echec + * (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente + * chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le + * fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides. + * Retourne true si au moins un bloc a echoue. + */ + async function submitRows( + rows: T[], + target: Ref[]>, + saveRow: (row: T, index: number) => Promise, + onUnmappedError: (error: unknown, index: number) => void, + shouldSkip?: (row: T, index: number) => boolean, + ): Promise { + target.value = [] + let hasError = false + for (let index = 0; index < rows.length; index++) { + const row = rows[index] as T + if (shouldSkip?.(row, index)) { + continue + } + try { + await saveRow(row, index) + } + catch (error) { + if (!mapRowError(error, target, index)) { + onUnmappedError(error, index) + } + hasError = true + } + } + + return hasError + } + + return { + mainErrors, + informationErrors, + accountingErrors, + contactErrors, + addressErrors, + ribErrors, + mapRowError, + submitRows, + } +} diff --git a/frontend/modules/commercial/composables/useSupplierReferentials.ts b/frontend/modules/commercial/composables/useSupplierReferentials.ts new file mode 100644 index 0000000..44dfa2e --- /dev/null +++ b/frontend/modules/commercial/composables/useSupplierReferentials.ts @@ -0,0 +1,118 @@ +import { ref } from 'vue' + +/** + * Charge les referentiels (listes courtes) alimentant les selects de l'ecran + * « Ajouter un fournisseur » : categories (type FOURNISSEUR), sites, modes de TVA, + * delais et types de reglement, banques. Miroir de `useClientReferentials` (M1). + * + * Toutes les collections sont recuperees en entier via l'echappatoire prevue + * `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec + * l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir + * l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`) + * renvoyees telles quelles dans les payloads POST/PATCH (relations M:1 / M:N). + * + * Difference M2 : pas de distributeurs/courtiers (absents du modele fournisseur). + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. + */ + +/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */ +export interface RefOption { + value: string + label: string +} + +/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */ +export interface PaymentTypeOption extends RefOption { + code: string +} + +/** Option de categorie enrichie de son code stable. */ +export interface CategoryOption extends RefOption { + code: string +} + +interface HydraMember { + '@id': string +} + +interface CategoryMember extends HydraMember { + code: string + name: string +} + +interface SiteMember extends HydraMember { + name: string + postalCode: string +} + +interface ReferentialMember extends HydraMember { + code: string + label: string +} + +const LD_JSON_HEADERS = { Accept: 'application/ld+json' } + +export function useSupplierReferentials() { + const api = useApi() + + const categories = ref([]) + const sites = ref([]) + const tvaModes = ref([]) + const paymentDelays = ref([]) + const paymentTypes = ref([]) + const banks = ref([]) + + /** Recupere une collection complete (pagination desactivee) en Hydra. */ + async function fetchAll( + url: string, + query: Record = {}, + ): Promise { + const res = await api.get<{ member?: T[] }>( + url, + { pagination: 'false', ...query }, + { headers: LD_JSON_HEADERS, toast: false }, + ) + return res.member ?? [] + } + + /** + * Charge en parallele les referentiels communs. + * + * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole. + * Necessaire pour les roles metier qui n'ont pas toutes les permissions de + * lecture (ex. Compta a `commercial.suppliers.view` mais pas forcement + * `catalog.categories.view` ni `sites.view`). Un referentiel en echec reste + * simplement vide. + */ + async function loadCommon(): Promise { + await Promise.allSettled([ + // Taxonomie multi-types (ERP-84) : un fournisseur ne porte que des + // categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API. + fetchAll('/categories', { typeCode: 'FOURNISSEUR' }) + .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), + fetchAll('/sites') + // Libelle = numero de departement (2 premiers chiffres du code + // postal du site), ex: 86100 -> « 86 ». + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), + fetchAll('/tva_modes') + .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), + fetchAll('/payment_delays') + .then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }), + fetchAll('/payment_types') + .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), + fetchAll('/banks') + .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), + ]) + } + + return { + categories, + sites, + tvaModes, + paymentDelays, + paymentTypes, + banks, + loadCommon, + } +} diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue new file mode 100644 index 0000000..9f7ab4b --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -0,0 +1,864 @@ + + + diff --git a/frontend/modules/commercial/types/supplierForm.ts b/frontend/modules/commercial/types/supplierForm.ts new file mode 100644 index 0000000..4f72ce6 --- /dev/null +++ b/frontend/modules/commercial/types/supplierForm.ts @@ -0,0 +1,109 @@ +/** + * Types « brouillon » de l'ecran « Ajouter un fournisseur » (M2 Commercial). + * + * Miroir de `types/clientForm.ts` (M1). Ces interfaces decrivent l'etat LOCAL du + * formulaire (refs Vue), distinct des DTO de l'API : elles portent en plus des + * champs purement UI (`hasSecondaryPhone`) et l'`iri` Hydra des entites creees + * (necessaire pour rattacher une adresse a des contacts deja persistes, M2M). + * Partage par la page de creation et les blocs `SupplierContactBlock` / + * `SupplierAddressBlock` (reutilises par la consultation/modification 95/96). + * + * Differences M2 vs M1 (cf. spec-front § « Differences notables ») : + * - Adresse : type via enum exclusif `addressType` (PROSPECT/DEPART/RENDU, + * RG-2.09) — pas de drapeaux isProspect/isDelivery/isBilling. + * - Adresse : champs specifiques fournisseur `bennes` (nombre) et + * `triageProvider` (prestation de triage). Pas d'email de facturation. + * - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal. + */ + +/** Type d'adresse fournisseur (enum exclusif RG-2.09). */ +export type SupplierAddressType = 'PROSPECT' | 'DEPART' | 'RENDU' + +/** Un contact du fournisseur (onglet Contacts). */ +export interface SupplierContactFormDraft { + /** Id serveur une fois le contact cree (null tant que non persiste). */ + id: number | null + /** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */ + iri: string | null + firstName: string | null + lastName: string | null + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null + /** UI : le 2e numero a ete revele via le bouton « + ». */ + hasSecondaryPhone: boolean +} + +/** Une adresse du fournisseur (onglet Adresses). */ +export interface SupplierAddressFormDraft { + id: number | null + /** Type exclusif Prospect / Depart / Rendu (RG-2.09). null tant que non choisi. */ + addressType: SupplierAddressType | null + country: string + postalCode: string | null + city: string | null + street: string | null + streetComplement: string | null + /** IRI des categories rattachees (type FOURNISSEUR, RG-2.10). */ + categoryIris: string[] + /** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-2.06). */ + siteIris: string[] + /** IRI des contacts rattaches (= blocs Contact deja crees). */ + contactIris: string[] + /** Nombre de bennes (stepper, defaut 0). Chaine pour MalioInputNumber, convertie au payload. */ + bennes: string | null + /** Prestation de triage (specifique fournisseur, portee par l'adresse — RG). */ + triageProvider: boolean +} + +/** Un RIB du fournisseur (onglet Comptabilite). */ +export interface SupplierRibFormDraft { + id: number | null + label: string | null + bic: string | null + iban: string | null +} + +/** Fabrique un contact vierge. */ +export function emptyContact(): SupplierContactFormDraft { + return { + id: null, + iri: null, + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + hasSecondaryPhone: false, + } +} + +/** Fabrique une adresse vierge (pays prerempli « France », 0 benne). */ +export function emptyAddress(): SupplierAddressFormDraft { + return { + id: null, + addressType: null, + country: 'France', + postalCode: null, + city: null, + street: null, + streetComplement: null, + categoryIris: [], + siteIris: [], + contactIris: [], + bennes: '0', + triageProvider: false, + } +} + +/** Fabrique un RIB vierge. */ +export function emptyRib(): SupplierRibFormDraft { + return { + id: null, + label: null, + bic: null, + iban: null, + } +} diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts new file mode 100644 index 0000000..11b2570 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { + buildAccountingPayload, + buildAddressPayload, + buildContactPayload, + buildInformationPayload, + buildMainPayload, + buildRibPayload, +} from '../supplierEdit' +import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm' + +describe('buildMainPayload (groupe supplier:write:main)', () => { + it('envoie companyName + categories quand renseignes', () => { + expect(buildMainPayload({ companyName: 'ACME', categoryIris: ['/api/categories/1'] })).toEqual({ + companyName: 'ACME', + categories: ['/api/categories/1'], + }) + }) + + it('omet companyName vide (-> 422 NotBlank, ERP-119)', () => { + const payload = buildMainPayload({ companyName: null, categoryIris: [] }) + expect('companyName' in payload).toBe(false) + expect(payload.categories).toEqual([]) + }) +}) + +describe('buildInformationPayload (groupe supplier:write:information)', () => { + const base = { + description: null, competitors: null, foundedAt: null, employeesCount: null, + revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null, + } + + it('convertit employeesCount et volumeForecast en nombre, null si vide', () => { + expect(buildInformationPayload({ ...base, employeesCount: '42', volumeForecast: '1000' })).toMatchObject({ + employeesCount: 42, + volumeForecast: 1000, + }) + expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null }) + }) +}) + +describe('buildContactPayload (sous-ressource supplier_contact)', () => { + it('n\'envoie le 2e telephone que si revele (hasSecondaryPhone)', () => { + const contact = { ...emptyContact(), phonePrimary: '0102030405', phoneSecondary: '0607080910' } + expect(buildContactPayload({ ...contact, hasSecondaryPhone: false }).phoneSecondary).toBeNull() + expect(buildContactPayload({ ...contact, hasSecondaryPhone: true }).phoneSecondary).toBe('0607080910') + }) +}) + +describe('buildAddressPayload (sous-ressource supplier_address — specificites M2)', () => { + it('envoie addressType (enum), bennes (nombre) et triageProvider', () => { + const address = { + ...emptyAddress(), + addressType: 'RENDU' as const, + postalCode: '86100', city: 'Châtellerault', street: '1 rue de la Paix', + siteIris: ['/api/sites/1'], categoryIris: ['/api/categories/2'], + bennes: '3', triageProvider: true, + } + expect(buildAddressPayload(address)).toMatchObject({ + addressType: 'RENDU', + bennes: 3, + triageProvider: true, + sites: ['/api/sites/1'], + categories: ['/api/categories/2'], + }) + }) + + it('bennes null quand le champ est vide', () => { + expect(buildAddressPayload({ ...emptyAddress(), bennes: '' }).bennes).toBeNull() + }) + + it('omet postalCode / city / street vides (-> 422 NotBlank, ERP-119)', () => { + const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'PROSPECT' }) + expect('postalCode' in payload).toBe(false) + expect('city' in payload).toBe(false) + expect('street' in payload).toBe(false) + // Les champs non requis restent presents. + expect('streetComplement' in payload).toBe(true) + expect(payload.addressType).toBe('PROSPECT') + }) + + it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => { + // emptyAddress() laisse addressType a null : la cle doit etre absente du + // payload pour que le back renvoie une 422 propertyPath addressType. + const payload = buildAddressPayload(emptyAddress()) + expect('addressType' in payload).toBe(false) + }) + + it('n\'expose jamais d\'email de facturation (difference M1)', () => { + const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' }) + expect('billingEmail' in payload).toBe(false) + }) +}) + +describe('buildAccountingPayload (groupe supplier:write:accounting)', () => { + const base = { + siren: '123456789', accountNumber: '00012345678', nTva: 'FR123', + tvaModeIri: '/api/tva_modes/1', paymentDelayIri: '/api/payment_delays/1', + paymentTypeIri: '/api/payment_types/1', bankIri: '/api/banks/1', + } + + it('envoie la banque seulement si requise (VIREMENT, RG-2.07)', () => { + expect(buildAccountingPayload(base, true).bank).toBe('/api/banks/1') + expect(buildAccountingPayload(base, false).bank).toBeNull() + }) +}) + +describe('buildRibPayload (sous-ressource supplier_rib)', () => { + it('omet les champs requis vides (-> 422 NotBlank, ERP-119)', () => { + const payload = buildRibPayload({ ...emptyRib(), iban: 'FR1420041010050500013M02606' }) + expect('label' in payload).toBe(false) + expect('bic' in payload).toBe(false) + expect(payload.iban).toBe('FR1420041010050500013M02606') + }) +}) diff --git a/frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts new file mode 100644 index 0000000..1b63eb5 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/supplierFormRules.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest' +import { + buildSupplierFormTabKeys, + hasAtLeastOneValidContact, + isAddressValid, + isBankRequiredForPaymentType, + isBlankRow, + isContactBlank, + isContactNamed, + isRibBlank, + isRibComplete, + isRibRequiredForPaymentType, + lastFillableTabKey, + omitEmptyRequired, + type AddressValidityDraft, + type ContactDraft, + type ContactFillableDraft, +} from '../supplierFormRules' + +/** Bloc contact totalement vide (amorce par defaut). */ +function blankContact(): ContactFillableDraft { + return { + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + } +} + +describe('buildSupplierFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => { + it('inclut l onglet accounting si l utilisateur a accounting.view', () => { + expect(buildSupplierFormTabKeys(true)).toContain('accounting') + }) + + it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => { + expect(buildSupplierFormTabKeys(false)).not.toContain('accounting') + }) + + it('a la creation, ordre = information / contacts / addresses / transport (+ accounting si vu)', () => { + expect(buildSupplierFormTabKeys(true)).toEqual(['information', 'contacts', 'addresses', 'transport', 'accounting']) + expect(buildSupplierFormTabKeys(false)).toEqual(['information', 'contacts', 'addresses', 'transport']) + }) + + it('a la creation, exclut Statistiques / Rapports / Echanges', () => { + const keys = buildSupplierFormTabKeys(true) + expect(keys).not.toContain('statistics') + expect(keys).not.toContain('reports') + expect(keys).not.toContain('exchanges') + }) + + it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => { + expect(buildSupplierFormTabKeys(true, { includeEditOnlyTabs: true })).toEqual([ + 'information', 'contacts', 'addresses', 'transport', 'accounting', 'statistics', 'reports', 'exchanges', + ]) + }) +}) + +describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => { + it('addresses pour un role sans Comptabilite (Bureau / Commerciale)', () => { + expect(lastFillableTabKey(buildSupplierFormTabKeys(false))).toBe('addresses') + }) + + it('accounting pour un role avec accounting.view (Admin)', () => { + expect(lastFillableTabKey(buildSupplierFormTabKeys(true))).toBe('accounting') + }) + + it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => { + expect(lastFillableTabKey(['information', 'contacts', 'addresses', 'transport'])).toBe('addresses') + }) + + it('undefined si aucun onglet remplissable (que des placeholders)', () => { + expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined() + }) +}) + +describe('isContactNamed (RG-2.04)', () => { + it('vrai si le prenom ou le nom est renseigne', () => { + expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) + expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true) + }) + + it('faux si les deux sont vides ou espaces uniquement', () => { + expect(isContactNamed({ firstName: null, lastName: null })).toBe(false) + expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false) + }) +}) + +describe('hasAtLeastOneValidContact (RG-2.13)', () => { + it('faux sur une liste vide ou sans contact nomme', () => { + expect(hasAtLeastOneValidContact([])).toBe(false) + const contacts: ContactDraft[] = [{ firstName: null, lastName: null }, { firstName: '', lastName: ' ' }] + expect(hasAtLeastOneValidContact(contacts)).toBe(false) + }) + + it('vrai des qu un contact a un nom ou un prenom', () => { + expect(hasAtLeastOneValidContact([{ firstName: null, lastName: null }, { firstName: 'Bob', lastName: null }])).toBe(true) + }) +}) + +describe('isBlankRow / isContactBlank / isRibBlank (blocs vides vs partiels)', () => { + it('isBlankRow vrai si toutes les valeurs sont vides', () => { + expect(isBlankRow([null, undefined, '', ' '])).toBe(true) + expect(isBlankRow([null, 'x', ''])).toBe(false) + }) + + it('isContactBlank faux si un email seul est saisi (bloc a soumettre -> 422 RG-2.04 inline)', () => { + expect(isContactBlank(blankContact())).toBe(true) + expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false) + }) + + it('isRibBlank faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => { + expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true) + expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false) + }) +}) + +describe('isRibComplete (gating « + RIB » + RG-2.08)', () => { + it('vrai quand label + BIC + IBAN sont remplis', () => { + expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true) + }) + + it('faux si un champ manque', () => { + expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false) + expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false) + }) +}) + +describe('regles type de reglement (RG-2.07 / RG-2.08)', () => { + it('banque obligatoire si VIREMENT', () => { + expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) + expect(isBankRequiredForPaymentType('LCR')).toBe(false) + expect(isBankRequiredForPaymentType(null)).toBe(false) + }) + + it('RIB obligatoire si LCR', () => { + expect(isRibRequiredForPaymentType('LCR')).toBe(true) + expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false) + expect(isRibRequiredForPaymentType(null)).toBe(false) + }) +}) + +describe('isAddressValid (enum addressType, RG-2.06/2.09/2.10 ; pas d\'email facturation)', () => { + function validAddress(): AddressValidityDraft { + return { + addressType: 'DEPART', + categoryIris: ['/api/categories/1'], + siteIris: ['/api/sites/1'], + } + } + + it('vrai quand type + >= 1 site + >= 1 categorie', () => { + expect(isAddressValid(validAddress())).toBe(true) + }) + + it('faux si le type d\'adresse n\'est pas renseigne (amorce vierge)', () => { + expect(isAddressValid({ ...validAddress(), addressType: null })).toBe(false) + }) + + it('faux si aucun site (RG-2.06)', () => { + expect(isAddressValid({ ...validAddress(), siteIris: [] })).toBe(false) + }) + + it('faux si aucune categorie (RG-2.10)', () => { + expect(isAddressValid({ ...validAddress(), categoryIris: [] })).toBe(false) + }) + + it('accepte les trois valeurs d\'enum PROSPECT / DEPART / RENDU', () => { + for (const type of ['PROSPECT', 'DEPART', 'RENDU'] as const) { + expect(isAddressValid({ ...validAddress(), addressType: type })).toBe(true) + } + }) +}) + +describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => { + it('retire les cles requises vides et conserve le reste', () => { + const payload = omitEmptyRequired( + { companyName: null, sites: ['/api/sites/1'] }, + ['companyName'], + ) + expect('companyName' in payload).toBe(false) + expect(payload.sites).toEqual(['/api/sites/1']) + }) + + it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => { + const payload = omitEmptyRequired({ triageProvider: false, bennes: 0 }, ['triageProvider', 'bennes']) + expect(payload).toEqual({ triageProvider: false, bennes: 0 }) + }) +}) diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts new file mode 100644 index 0000000..a0f793a --- /dev/null +++ b/frontend/modules/commercial/utils/supplierEdit.ts @@ -0,0 +1,142 @@ +/** + * Helpers purs de payload de l'ecran « Ajouter un fournisseur » (M2 Commercial), + * partages avec la future modification (96) — miroir de `clientEdit.ts` (M1). + * + * Scoping STRICT des payloads (mode strict, aligne ERP-74/RG) : chaque onglet + * n'envoie QUE les champs de SON groupe de serialisation, jamais un payload mixte + * (un champ hors-permission = 403 sur l'integralite cote back). Ces helpers ne + * touchent ni a l'API ni a l'etat reactif. + */ + +import { + ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + MAIN_REQUIRED_NON_NULLABLE_KEYS, + omitEmptyRequired, + RIB_REQUIRED_NON_NULLABLE_KEYS, +} from '~/modules/commercial/utils/supplierFormRules' +import type { + SupplierAddressFormDraft, + SupplierContactFormDraft, + SupplierRibFormDraft, +} from '~/modules/commercial/types/supplierForm' + +/** Etat « plat » du bloc principal (groupe supplier:write:main). */ +export interface MainFormDraft { + companyName: string | null + /** IRI des categories rattachees (M2M, type FOURNISSEUR). */ + categoryIris: string[] +} + +/** Etat « plat » de l'onglet Information (groupe supplier:write:information). */ +export interface InformationFormDraft { + description: string | null + competitors: string | null + /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ + foundedAt: string | null + /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ + employeesCount: string | null + revenueAmount: string | null + profitAmount: string | null + directorName: string | null + /** Volume previsionnel (entier >= 0, specifique fournisseur) en chaine. */ + volumeForecast: string | null +} + +/** Etat « plat » de l'onglet Comptabilite (groupe supplier:write:accounting). */ +export interface AccountingFormDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + bankIri: string | null +} + +/** + * Payload du bloc principal — groupe supplier:write:main UNIQUEMENT. + * companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + */ +export function buildMainPayload(main: MainFormDraft): Record { + return omitEmptyRequired({ + companyName: main.companyName, + categories: main.categoryIris, + }, MAIN_REQUIRED_NON_NULLABLE_KEYS) +} + +/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */ +export function buildInformationPayload(information: InformationFormDraft): Record { + return { + description: information.description || null, + competitors: information.competitors || null, + foundedAt: information.foundedAt || null, + employeesCount: information.employeesCount ? Number(information.employeesCount) : null, + revenueAmount: information.revenueAmount || null, + profitAmount: information.profitAmount || null, + directorName: information.directorName || null, + volumeForecast: information.volumeForecast ? Number(information.volumeForecast) : null, + } +} + +/** + * Payload des scalaires de l'onglet Comptabilite — groupe supplier:write:accounting + * UNIQUEMENT (les RIB passent par la sous-ressource /suppliers/{id}/ribs). La + * banque n'a de sens que pour un Virement (RG-2.07) : forcee a null sinon. + */ +export function buildAccountingPayload( + accounting: AccountingFormDraft, + isBankRequired: boolean, +): Record { + return { + siren: accounting.siren || null, + accountNumber: accounting.accountNumber || null, + tvaMode: accounting.tvaModeIri, + nTva: accounting.nTva || null, + paymentDelay: accounting.paymentDelayIri, + paymentType: accounting.paymentTypeIri, + bank: isBankRequired ? accounting.bankIri : null, + } +} + +/** Payload d'un contact (sous-ressource supplier_contact). */ +export function buildContactPayload(contact: SupplierContactFormDraft): Record { + return { + firstName: contact.firstName || null, + lastName: contact.lastName || null, + jobTitle: contact.jobTitle || null, + phonePrimary: contact.phonePrimary || null, + phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null, + email: contact.email || null, + } +} + +/** + * Payload d'une adresse (sous-ressource supplier_address). postalCode / city / + * street omis si vides -> 422 NotBlank (ERP-119). Specifique fournisseur : + * `bennes` (entier, 0 par defaut) + `triageProvider` (booleen). Pas d'email de + * facturation (difference M1). + */ +export function buildAddressPayload(address: SupplierAddressFormDraft): Record { + return omitEmptyRequired({ + addressType: address.addressType, + country: address.country, + postalCode: address.postalCode || null, + city: address.city || null, + street: address.street || null, + streetComplement: address.streetComplement || null, + categories: address.categoryIris, + sites: address.siteIris, + contacts: address.contactIris, + bennes: address.bennes !== null && address.bennes !== '' ? Number(address.bennes) : null, + triageProvider: address.triageProvider, + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) +} + +/** Payload d'un RIB (sous-ressource supplier_rib). */ +export function buildRibPayload(rib: SupplierRibFormDraft): Record { + return omitEmptyRequired({ + label: rib.label, + bic: rib.bic, + iban: rib.iban, + }, RIB_REQUIRED_NON_NULLABLE_KEYS) +} diff --git a/frontend/modules/commercial/utils/supplierFormRules.ts b/frontend/modules/commercial/utils/supplierFormRules.ts new file mode 100644 index 0000000..a2cbc75 --- /dev/null +++ b/frontend/modules/commercial/utils/supplierFormRules.ts @@ -0,0 +1,219 @@ +/** + * Regles metier pures de l'ecran « Ajouter un fournisseur » (M2 Commercial). + * + * Miroir de `clientFormRules.ts` (M1), centralisees ici (hors composant) pour + * rester testables unitairement et partagees entre la creation et les ecrans + * d'edition/consultation (95/96). Ces helpers ne touchent ni a l'API ni a l'etat + * reactif : ils prennent des brouillons « plats » et retournent des booleens. + * + * Le back reste la source de verite (les RG sont re-validees serveur, mode + * strict) ; ces regles ne servent qu'au feedback UI immediat (gating de boutons). + * + * Differences M2 vs M1 : + * - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de + * drapeaux ni d'exclusivite a gerer cote front (le radio est exclusif par nature). + * - Pas d'email de facturation, pas de relation Distributeur/Courtier. + */ + +import type { SupplierAddressType } from '~/modules/commercial/types/supplierForm' + +/** + * Onglets « coquille » (non encore implementes) : frame vide, passage + * automatique a l'onglet suivant (aligne M1). + */ +export const SUPPLIER_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const + +/** + * Onglets affiches uniquement en MODIFICATION/CONSULTATION (jamais a la + * creation) : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans + * 95/96 via l'option `includeEditOnlyTabs`. + */ +export const SUPPLIER_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const + +/** + * Construit l'ordre des onglets du formulaire fournisseur. + * - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view` + * (Bureau / Commerciale ne le voient pas). + * - Les onglets edit-only sont exclus par defaut (creation) ; passer + * `includeEditOnlyTabs: true` pour les afficher en modification/consultation. + * Ordre aligne sur la spec M2 § Ecran « Ajouter un fournisseur » (barre 5 onglets). + */ +export function buildSupplierFormTabKeys( + canAccountingView: boolean, + options: { includeEditOnlyTabs?: boolean } = {}, +): string[] { + const keys = ['information', 'contacts', 'addresses', 'transport'] + if (canAccountingView) { + keys.push('accounting') + } + if (options.includeEditOnlyTabs) { + keys.push(...SUPPLIER_FORM_EDIT_ONLY_TABS) + } + return keys +} + +/** + * Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un + * placeholder. Role-aware sans regle ad hoc — il suffit de lui passer les + * `tabKeys` deja filtres par permission. Sa validation marque la fin de l'ajout. + */ +export function lastFillableTabKey(tabKeys: string[]): string | undefined { + return [...tabKeys].reverse().find( + key => !(SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key), + ) +} + +/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-2.04/2.13). */ +export interface ContactDraft { + firstName: string | null + lastName: string | null +} + +/** Vrai si une chaine porte au moins un caractere non-espace. */ +function isFilled(value: string | null | undefined): boolean { + return value !== null && value !== undefined && value.trim() !== '' +} + +/** RG-2.04 : un contact est valide des qu'il porte un nom OU un prenom. */ +export function isContactNamed(contact: ContactDraft): boolean { + return isFilled(contact.firstName) || isFilled(contact.lastName) +} + +/** + * RG-2.13 : l'onglet Contacts ne peut etre finalise que s'il reste au moins un + * contact nomme (nom ou prenom). + */ +export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean { + return contacts.some(isContactNamed) +} + +/** + * Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides. Sert a + * detecter un bloc de collection totalement vide (amorce non remplie). Un bloc qui + * porte la moindre donnee n'est PAS « blank » : il doit etre soumis pour declencher + * sa 422 inline plutot que d'etre saute silencieusement. + */ +export function isBlankRow(values: (string | null | undefined)[]): boolean { + return values.every(value => !isFilled(value)) +} + +/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */ +export interface ContactFillableDraft extends ContactDraft { + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null +} + +/** + * Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc + * d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom + * (email/telephone/fonction seul) : ce dernier doit etre soumis pour declencher la + * 422 RG-2.04 affichee inline. + */ +export function isContactBlank(contact: ContactFillableDraft): boolean { + return isBlankRow([ + contact.firstName, + contact.lastName, + contact.jobTitle, + contact.phonePrimary, + contact.phoneSecondary, + contact.email, + ]) +} + +/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */ +export interface RibFillableDraft { + label: string | null + bic: string | null + iban: string | null +} + +/** + * Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex. + * IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422 + * NotBlank inline plutot que d'etre saute silencieusement. + */ +export function isRibBlank(rib: RibFillableDraft): boolean { + return isBlankRow([rib.label, rib.bic, rib.iban]) +} + +/** + * RG-2.08 : un RIB est complet quand ses trois champs sont remplis (label, BIC, + * IBAN). Predicat partage entre le gating du bouton « + RIB » et la validation de + * l'onglet (au moins un RIB complet si reglement LCR). + */ +export function isRibComplete(rib: RibFillableDraft): boolean { + return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban) +} + +/** + * Sous-ensemble d'une adresse necessaire a sa validite par-bloc : type (enum), + * sites et categories rattaches. + */ +export interface AddressValidityDraft { + addressType: SupplierAddressType | null + categoryIris: string[] + siteIris: string[] +} + +/** + * Validite par-bloc d'une adresse : type renseigne (RG-2.09), >= 1 site (RG-2.06) + * et >= 1 categorie (RG-2.10). Predicat partage entre le gating du bouton + * « + Adresse » (le dernier bloc doit etre valide avant d'en ajouter un autre) et + * la validation de l'onglet (toutes les adresses valides). Pas d'email de + * facturation cote fournisseur (difference M1). + */ +export function isAddressValid(address: AddressValidityDraft): boolean { + return address.addressType !== null + && address.siteIris.length >= 1 + && address.categoryIris.length >= 1 +} + +/** Code stable du type de reglement « virement » (RG-2.07). */ +const PAYMENT_TYPE_TRANSFER = 'VIREMENT' + +/** Code stable du type de reglement « lettre de change » (RG-2.08). */ +const PAYMENT_TYPE_LCR = 'LCR' + +/** RG-2.07 : la banque est obligatoire lorsque le type de reglement est un virement. */ +export function isBankRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_TRANSFER +} + +/** RG-2.08 : au moins un RIB complet est obligatoire lorsque le type de reglement est une LCR. */ +export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_LCR +} + +// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ─────────────── +// Memes contraintes qu'au M1 : un champ requis (NotBlank) porte par une colonne +// Doctrine NON nullable rejette `null` en 400 de TYPE avant le Validator. Parade : +// OMETTRE la cle du payload quand elle est vide -> le back produit une 422 NotBlank +// avec propertyPath, mappee en rouge sous le champ. +export const MAIN_REQUIRED_NON_NULLABLE_KEYS = ['companyName'] as const +// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio +// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator +// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On +// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath. +export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', 'postalCode', 'city', 'street'] as const +export const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const + +/** + * Retire d'un payload d'ecriture les cles requises laissees vides (null / '' / + * undefined), pour laisser le back produire une 422 NotBlank par champ plutot + * qu'un 400 de type sur une colonne non-nullable. Mute et retourne le payload. + */ +export function omitEmptyRequired>( + payload: T, + requiredKeys: readonly string[], +): T { + for (const key of requiredKeys) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + delete payload[key] + } + } + + return payload +} diff --git a/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php new file mode 100644 index 0000000..486a7bb --- /dev/null +++ b/src/Module/Commercial/Application/Validator/SupplierAccountingCompletenessValidator.php @@ -0,0 +1,78 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $supplier->getSiren(), + 'accountNumber' => $supplier->getAccountNumber(), + 'tvaMode' => $supplier->getTvaMode(), + 'nTva' => $supplier->getNTva(), + 'paymentDelay' => $supplier->getPaymentDelay(), + 'paymentType' => $supplier->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + null, + [], + $supplier, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php deleted file mode 100644 index d6d4f03..0000000 --- a/src/Module/Commercial/Application/Validator/SupplierInformationCompletenessValidator.php +++ /dev/null @@ -1,82 +0,0 @@ - valeur courante de l'onglet Information. - $fields = [ - 'description' => $supplier->getDescription(), - 'competitors' => $supplier->getCompetitors(), - 'foundedAt' => $supplier->getFoundedAt(), - 'employeesCount' => $supplier->getEmployeesCount(), - 'revenueAmount' => $supplier->getRevenueAmount(), - 'directorName' => $supplier->getDirectorName(), - 'profitAmount' => $supplier->getProfitAmount(), - 'volumeForecast' => $supplier->getVolumeForecast(), - ]; - - $violations = new ConstraintViolationList(); - - foreach ($fields as $property => $value) { - if ($this->isMissing($value)) { - $violations->add(new ConstraintViolation( - // Pas de nom de champ technique dans le message : la violation est - // deja rattachee au bon champ via son propertyPath (mappe inline - // cote front par useFormErrors). - 'Ce champ est obligatoire pour le rôle Commerciale.', - null, - [], - $supplier, - $property, - $value, - )); - } - } - - if (count($violations) > 0) { - throw new ValidationException($violations); - } - } - - /** - * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les - * zeros numeriques (employeesCount = 0, profitAmount = "0.00", - * volumeForecast = 0) sont des valeurs valides : on ne les considere pas - * manquants. - */ - private function isMissing(mixed $value): bool - { - if (null === $value) { - return true; - } - - return is_string($value) && '' === trim($value); - } -} diff --git a/src/Module/Commercial/Domain/Entity/Supplier.php b/src/Module/Commercial/Domain/Entity/Supplier.php index 7c7d7f1..30709a1 100644 --- a/src/Module/Commercial/Domain/Entity/Supplier.php +++ b/src/Module/Commercial/Domain/Entity/Supplier.php @@ -328,8 +328,11 @@ class Supplier implements TimestampableInterface, BlamableInterface * chaque 422 porte un propertyPath exploitable par extractApiViolations * (mapping inline sous le champ, pas un toast — convention ERP-101). * - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`. - * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs` - * (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88). + * - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur + * `paymentType` (miroir client : `ribs` n'a pas de champ de formulaire ou + * s'ancrer quand la liste est vide ; l'erreur s'affiche donc sous le select + * « Type de règlement », bindé cote front). Le 409 sur DELETE du dernier RIB + * en LCR est porte par ERP-88. * * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui * n'expose que supplier:write:main), la contrainte ne mord en pratique que @@ -349,7 +352,7 @@ class Supplier implements TimestampableInterface, BlamableInterface if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) { $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.') - ->atPath('ribs') + ->atPath('paymentType') ->addViolation() ; } diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php index 6667675..94a3aaf 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php +++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php @@ -199,12 +199,14 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $contacts; - // RG-2.10 : categories d'adresse de type FOURNISSEUR (controle au Processor). + // RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est + // controle par validateCategoryType ; le minimum par Assert\Count, miroir sites). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinColumn(name: 'supplier_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['supplier:item:read', 'supplier:write:addresses'])] private Collection $categories; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php index e66eb3c..c94a604 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php @@ -7,10 +7,8 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Module\Commercial\Application\Service\SupplierFieldNormalizer; -use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator; +use App\Module\Commercial\Application\Validator\SupplierAccountingCompletenessValidator; use App\Module\Commercial\Domain\Entity\Supplier; -use App\Shared\Domain\Contract\BusinessRoleAwareInterface; -use App\Shared\Domain\Security\BusinessRoles; use DateTimeImmutable; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -43,19 +41,17 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; * collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de * restauration). * - * Validators metier (ERP-89). Decision figee : ce processor ne porte QUE - * RG-2.03 (completude Information exigee pour le role Commerciale — detection du - * role cote back, non exprimable en contrainte d'entite). Les RG inter-champs - * RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie - * de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur - * l'entite Supplier (jouees par API Platform AVANT ce processor), pour que - * chaque 422 porte un propertyPath consommable par extractApiViolations - * (mapping inline, pas un toast — convention ERP-101). + * Validators metier (ERP-89). Ce processor porte la completude Comptabilite : a + * la validation complete de l'onglet (les six scalaires obligatoires presents + * dans le payload), chacun doit etre renseigne. (RG-2.03 « Information obligatoire + * pour la Commerciale » a ete retiree, miroir client M1 — l'onglet Information est + * desormais entierement facultatif, quel que soit le role.) * - * Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories, - * les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce - * processor ; on n'y traite donc que les regles non exprimables en simples - * contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant). + * Les RG inter-champs RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et + * RG-2.10 (categorie de type FOURNISSEUR) sont portees par des Assert\Callback + + * ->atPath() sur l'entite Supplier (jouees par API Platform AVANT ce processor), + * pour que chaque 422 porte un propertyPath consommable par extractApiViolations + * (mapping inline, pas un toast — convention ERP-101). * * @implements ProcessorInterface */ @@ -78,6 +74,14 @@ final class SupplierProcessor implements ProcessorInterface 'paymentType', 'bank', ]; + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front M2 § Onglet Comptabilite). bank est exclu : conditionnel (RG-2.07). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + /** Champ d'archivage (groupe supplier:write:archive). */ private const string ARCHIVE_FIELD = 'isArchived'; @@ -102,7 +106,7 @@ final class SupplierProcessor implements ProcessorInterface #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] private readonly ProcessorInterface $persistProcessor, private readonly SupplierFieldNormalizer $normalizer, - private readonly SupplierInformationCompletenessValidator $informationValidator, + private readonly SupplierAccountingCompletenessValidator $accountingValidator, private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, @@ -132,7 +136,7 @@ final class SupplierProcessor implements ProcessorInterface // normalisees des deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); - $this->validateInformationCompleteness($data); + $this->validateAccountingCompleteness($data); try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); @@ -262,35 +266,23 @@ final class SupplierProcessor implements ProcessorInterface } /** - * RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les - * champs de l'onglet Information sont obligatoires sur POST comme sur TOUT - * PATCH — independamment des champs reellement envoyes. Garantit qu'un - * fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet - * Information incomplet. Pour les autres roles, ces champs restent optionnels. - * - * Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que - * supplier:write:main, une Commerciale obtient 422 sur tout POST tant que - * l'Information n'est pas complete -> la completude se fait via les PATCH - * supplier:write:information. + * spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs obligatoires presents dans le payload — le front les envoie + * toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne + * declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables : + * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / + * RIB restent geres par validatePaymentTypeConsistency sur l'entite (RG-2.07 / + * RG-2.08). Miroir du ClientProcessor (M1). */ - private function validateInformationCompleteness(Supplier $data): void + private function validateAccountingCompleteness(Supplier $data): void { - if ($this->currentUserIsCommerciale()) { - $this->informationValidator->validate($data); + // Declenche uniquement si TOUS les champs requis sont presents dans le + // payload (= soumission d'onglet, pas un PATCH partiel cible). + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; } - } - /** - * Detection du role metier Commerciale cote back (jamais front), via le - * contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE - * n°1). Identique au ClientProcessor (M1). - */ - private function currentUserIsCommerciale(): bool - { - $user = $this->security->getUser(); - - return $user instanceof BusinessRoleAwareInterface - && $user->hasBusinessRole(BusinessRoles::COMMERCIALE); + $this->accountingValidator->validate($data); } /** diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php index e7c5b97..97f4270 100644 --- a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -152,7 +152,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase $supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); $supplier->addCategory($this->supplierCategory('NEGOCIANT')); - // Onglet Information complet (RG-2.03 : exige pour la Commerciale). + // Onglet Information complet : donnees de reference pour les tests de + // lecture / serialisation / comptabilite (l'Information est facultative). $supplier->setDescription('Fournisseur de test complet.'); $supplier->setCompetitors('Concurrent A, Concurrent B'); $supplier->setFoundedAt(new DateTimeImmutable('2008-04-01')); diff --git a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php index 0819aee..9d19219 100644 --- a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php +++ b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php @@ -49,7 +49,7 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase // === RG-2.08 : LCR impose au moins un RIB === - public function testLcrWithoutRibReturns422OnRibsPath(): void + public function testLcrWithoutRibReturns422OnPaymentTypePath(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Lcr No Rib'); @@ -60,7 +60,9 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase ]); self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false))); + // Miroir client : violation portee sur `paymentType` (select « Type de + // règlement »), `ribs` n'ayant pas de champ de formulaire pour l'ancrer. + self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false))); } public function testLcrWithRibReturns200(): void @@ -77,5 +79,58 @@ final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase self::assertResponseStatusCodeSame(200); } + // === Completude de l'onglet Comptabilite (six scalaires obligatoires) === + + /** + * spec-front M2 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs requis presents dans le payload), chacun vide doit renvoyer + * une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir + * du comportement client (ClientAccountingCompletenessValidator). + */ + public function testIncompleteAccountingTabReturns422OnEachField(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Accounting Incomplete'); + + $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $paths = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('siren', $paths); + self::assertArrayHasKey('accountNumber', $paths); + self::assertArrayHasKey('tvaMode', $paths); + self::assertArrayHasKey('nTva', $paths); + self::assertArrayHasKey('paymentDelay', $paths); + self::assertArrayHasKey('paymentType', $paths); + } + + /** + * Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une + * validation d'onglet : la completude ne se declenche pas (edition ponctuelle + * preservee, cf. validateAccountingCompleteness). + */ + public function testPartialAccountingPatchSkipsCompleteness(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Accounting Partial'); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['nTva' => 'FR12345678901'], + ]); + + self::assertResponseStatusCodeSame(200); + } + // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase. } diff --git a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php index 9d5b3dd..55cc6d4 100644 --- a/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/SupplierRBACMatrixTest.php @@ -13,8 +13,8 @@ use Symfony\Component\Console\Output\NullOutput; /** * Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2 * § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour - * bureau / compta / commerciale / usine, le gating des champs comptables en - * lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH. + * bureau / compta / commerciale / usine et le gating des champs comptables en + * lecture (omission de cle). * * Les comptes demo et la matrice sont seedes via la commande reelle * `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente — @@ -23,7 +23,7 @@ use Symfony\Component\Console\Output\NullOutput; * Matrice § 2.9 (ERP-90) — rappel : * - bureau : suppliers.view + manage (ni accounting, ni archive) * - compta : suppliers.view + accounting.view + accounting.manage (PAS manage) - * - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03 + * - commerciale : suppliers.view + manage (PAS accounting) * - usine : aucune permission (403 partout) * - archive : admin seul (aucun role metier) * @@ -93,7 +93,7 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase $client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); - // manage : creation OK (bureau n'est pas gate par RG-2.03) + // manage : creation OK $client->request('POST', '/api/suppliers', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Bureau Created', $cat->getId()), @@ -211,15 +211,13 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase self::assertResponseStatusCodeSame(200); // manage : la creation passe la security d'operation (pas un 403 comme - // Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422. - $response = $client->request('POST', '/api/suppliers', [ + // Compta) -> 201. L'onglet Information est facultatif (RG-2.03 retiree, + // miroir client M1) : une Commerciale cree avec le seul onglet principal. + $client->request('POST', '/api/suppliers', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Commerciale Post'), ]); - self::assertResponseStatusCodeSame(422); - // Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un - // 422 orthogonal : on exige une violation sur un champ de completude. - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); + self::assertResponseStatusCodeSame(201); // PAS accounting : edition onglet Comptabilite refusee $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ @@ -251,50 +249,6 @@ final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase self::assertArrayNotHasKey('ribs', $data); } - public function testRG203CommercialePostIncompleteIs422AdminIs201(): void - { - $cat = $this->supplierCategory('NEGOCIANT'); - - // RG-2.03 : Commerciale POST sans onglet Information complet -> 422. - $commerciale = $this->authAs('commerciale'); - $response = $commerciale->request('POST', '/api/suppliers', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()), - ]); - self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); - - // Meme payload par un Admin (non gate par RG-2.03) -> 201. - $admin = $this->createAdminClient(); - $admin->request('POST', '/api/suppliers', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validMainPayload('RG203 Admin', $cat->getId()), - ]); - self::assertResponseStatusCodeSame(201); - } - - public function testRG203CommercialePatchIncompleteIs422(): void - { - // RG-2.03 : tout PATCH par une Commerciale exige l'Information complete. - // Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422. - $seed = $this->seedSupplier('Commerciale Patch Incomplete'); - $commerciale = $this->authAs('commerciale'); - - $response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['companyName' => 'Commerciale Renamed'], - ]); - self::assertResponseStatusCodeSame(422); - self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false))); - - // Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200. - $admin = $this->createAdminClient(); - $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['companyName' => 'Admin Renamed'], - ]); - self::assertResponseStatusCodeSame(200); - } private function authAs(string $role): Client { diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php index 3ded3be..ac1c8b8 100644 --- a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -172,8 +172,9 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedSupplier('Address Incoherent'); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Incoherent'); + $category = $this->supplierCategory('NEGOCIANT'); // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ @@ -184,6 +185,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase 'city' => 'Marseille', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); @@ -217,9 +219,10 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase public function testPostAddressWithEachValidTypeReturns201(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedSupplier('Address Types'); - $siteIri = $this->firstSiteIri(); + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Address Types'); + $siteIri = $this->firstSiteIri(); + $category = $this->supplierCategory('NEGOCIANT'); foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ @@ -230,6 +233,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type)); diff --git a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php index 04ec47f..511f966 100644 --- a/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php +++ b/tests/Module/Commercial/Domain/Entity/SupplierValidationTest.php @@ -87,12 +87,14 @@ final class SupplierValidationTest extends TestCase // === RG-2.08 : LCR impose au moins un RIB === - public function testLcrWithoutRibIsRejectedOnRibsPath(): void + public function testLcrWithoutRibIsRejectedOnPaymentTypePath(): void { $supplier = $this->validSupplier(); $supplier->setPaymentType($this->paymentType('LCR')); - self::assertContains('ribs', $this->violationPaths($supplier)); + // Miroir client : la violation LCR -> >= 1 RIB est portee sur `paymentType` + // (affichee sous le select « Type de règlement », `ribs` n'ayant pas de champ). + self::assertContains('paymentType', $this->violationPaths($supplier)); } public function testLcrWithRibPasses(): void @@ -101,7 +103,7 @@ final class SupplierValidationTest extends TestCase $supplier->setPaymentType($this->paymentType('LCR')); $supplier->addRib(new SupplierRib()); - self::assertNotContains('ribs', $this->violationPaths($supplier)); + self::assertNotContains('paymentType', $this->violationPaths($supplier)); } public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void diff --git a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php b/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php deleted file mode 100644 index 40cc545..0000000 --- a/tests/Module/Commercial/Unit/SupplierInformationCompletenessValidatorTest.php +++ /dev/null @@ -1,129 +0,0 @@ -completeSupplier(); - - $this->validator()->validate($supplier); - - // Aucune exception levee : la completude est satisfaite. - $this->addToAssertionCount(1); - } - - public function testEmptyInformationListsEveryMissingField(): void - { - $supplier = new Supplier(); - $supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information - - try { - $this->validator()->validate($supplier); - self::fail('Une ValidationException etait attendue (onglet Information vide).'); - } catch (ValidationException $e) { - $paths = []; - foreach ($e->getConstraintViolationList() as $violation) { - $paths[] = $violation->getPropertyPath(); - } - - // Les 8 champs Information (dont volumeForecast, NEW vs Client) sont - // tous signales d'un coup, chacun sous son propre propertyPath. - sort($paths); - self::assertSame([ - 'competitors', - 'description', - 'directorName', - 'employeesCount', - 'foundedAt', - 'profitAmount', - 'revenueAmount', - 'volumeForecast', - ], $paths); - } - } - - public function testPartialInformationReportsOnlyMissingFields(): void - { - $supplier = $this->completeSupplier(); - $supplier->setDirectorName(null); - $supplier->setVolumeForecast(null); - - try { - $this->validator()->validate($supplier); - self::fail('Une ValidationException etait attendue (2 champs manquants).'); - } catch (ValidationException $e) { - $paths = []; - foreach ($e->getConstraintViolationList() as $violation) { - $paths[] = $violation->getPropertyPath(); - } - - sort($paths); - self::assertSame(['directorName', 'volumeForecast'], $paths); - } - } - - public function testZeroNumericValuesAreNotMissing(): void - { - // employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des - // valeurs valides (un zero n'est pas une absence) -> pas de violation. - $supplier = $this->completeSupplier(); - $supplier->setEmployeesCount(0); - $supplier->setProfitAmount('0.00'); - $supplier->setVolumeForecast(0); - - $this->validator()->validate($supplier); - - $this->addToAssertionCount(1); - } - - public function testBlankStringIsMissing(): void - { - // Une chaine vide apres trim compte comme manquante. - $supplier = $this->completeSupplier(); - $supplier->setDescription(' '); - - $this->expectException(ValidationException::class); - $this->validator()->validate($supplier); - } - - /** - * Fournisseur dont l'onglet Information est entierement renseigne. - */ - private function completeSupplier(): Supplier - { - $supplier = new Supplier(); - $supplier->setCompanyName('Recycla SAS'); - $supplier->setDescription('Specialiste du recyclage'); - $supplier->setCompetitors('Concurrent A, Concurrent B'); - $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); - $supplier->setEmployeesCount(42); - $supplier->setRevenueAmount('1000000.00'); - $supplier->setDirectorName('Marie Durand'); - $supplier->setProfitAmount('150000.00'); - $supplier->setVolumeForecast(5000); - - return $supplier; - } - - private function validator(): SupplierInformationCompletenessValidator - { - return new SupplierInformationCompletenessValidator(); - } -} diff --git a/tests/Module/Commercial/Unit/SupplierProcessorTest.php b/tests/Module/Commercial/Unit/SupplierProcessorTest.php deleted file mode 100644 index 250ae0a..0000000 --- a/tests/Module/Commercial/Unit/SupplierProcessorTest.php +++ /dev/null @@ -1,244 +0,0 @@ - 422, meme - // sur un POST (les champs Information n'y sont pas renseignables). - $supplier = $this->minimalSupplier(); - $supplier->setDescription('Une description'); // les autres champs Information restent null - - $processor = $this->makeProcessor( - payload: ['description' => 'Une description'], - user: $this->commercialeUser(), - ); - - $this->expectException(ValidationException::class); - $processor->process($supplier, $this->operation()); - } - - public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void - { - // RG-2.03 : pour une Commerciale, la completude Information est exigee - // meme quand le payload ne touche PAS l'onglet Information (ici - // companyName seul) -> 422. - $supplier = $this->minimalSupplier(); - $supplier->setCompanyName('Renamed Co'); - - $processor = $this->makeProcessor( - granted: ['commercial.suppliers.manage'], - payload: ['companyName' => 'Renamed Co'], - user: $this->commercialeUser(), - managed: true, - originalData: [ - 'companyName' => 'TEST CO', - 'isArchived' => false, - ], - ); - - $this->expectException(ValidationException::class); - $processor->process($supplier, $this->operation()); - } - - public function testCommercialeCompleteInformationPasses(): void - { - // RG-2.03 satisfaite : tous les champs Information renseignes -> 200. - $supplier = $this->completeInformationSupplier(); - - $processor = $this->makeProcessor( - granted: ['commercial.suppliers.manage'], - payload: ['description' => 'desc'], - user: $this->commercialeUser(), - ); - - self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); - } - - public function testNonCommercialeSkipsInformationCompleteness(): void - { - // Meme onglet Information incomplet, mais user non-Commerciale -> aucun - // blocage (la completude est specifique a la Commerciale). - $supplier = $this->minimalSupplier(); - $supplier->setDescription('Une description'); - - $processor = $this->makeProcessor( - payload: ['description' => 'Une description'], - user: null, - ); - - self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); - } - - public function testAdminIncompleteInformationPasses(): void - { - // Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale - // (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role - // metier) n'est pas soumis a la completude Information -> 200 malgre un - // onglet Information incomplet. Prouve que le gate porte bien sur le ROLE - // metier Commerciale, et pas sur « il y a un utilisateur connecte ». - $supplier = $this->minimalSupplier(); - $supplier->setDescription('Une description'); - - $processor = $this->makeProcessor( - payload: ['description' => 'Une description'], - user: $this->adminUser(), - ); - - self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); - } - - /** - * @param list $granted Permissions accordees a l'utilisateur courant - * @param array $payload Corps JSON simule de la requete - * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST) - * @param array $originalData Etat persiste simule (getOriginalEntityData) - */ - private function makeProcessor( - array $granted = [], - array $payload = [], - ?UserInterface $user = null, - bool $managed = false, - array $originalData = [], - ): SupplierProcessor { - $persist = new class implements ProcessorInterface { - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed - { - return $data; - } - }; - - $security = $this->createStub(Security::class); - $security->method('isGranted')->willReturnCallback( - static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), - ); - $security->method('getUser')->willReturn($user); - - $requestStack = new RequestStack(); - $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); - - $uow = $this->createMock(UnitOfWork::class); - $uow->method('getOriginalEntityData')->willReturn($originalData); - - $em = $this->createMock(EntityManagerInterface::class); - $em->method('contains')->willReturn($managed); - $em->method('getUnitOfWork')->willReturn($uow); - - return new SupplierProcessor( - $persist, - new SupplierFieldNormalizer(), - new SupplierInformationCompletenessValidator(), - $security, - $requestStack, - $em, - ); - } - - private function minimalSupplier(): Supplier - { - $supplier = new Supplier(); - $supplier->setCompanyName('Test Co'); - - return $supplier; - } - - private function completeInformationSupplier(): Supplier - { - $supplier = $this->minimalSupplier(); - $supplier->setDescription('desc'); - $supplier->setCompetitors('concurrents'); - $supplier->setFoundedAt(new DateTimeImmutable('2010-01-01')); - $supplier->setEmployeesCount(10); - $supplier->setRevenueAmount('1000.00'); - $supplier->setDirectorName('Marie Durand'); - $supplier->setProfitAmount('100.00'); - $supplier->setVolumeForecast(500); - - return $supplier; - } - - private function operation(): Operation - { - return $this->createStub(Operation::class); - } - - /** - * Utilisateur authentifie non-Commerciale (profil admin) : porte - * BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a - * distinguer « pas de role Commerciale » de « pas d'utilisateur » (null). - */ - private function adminUser(): UserInterface - { - return new class implements UserInterface, BusinessRoleAwareInterface { - public function hasBusinessRole(string $roleCode): bool - { - return false; - } - - public function getRoles(): array - { - return ['ROLE_ADMIN']; - } - - public function eraseCredentials(): void {} - - public function getUserIdentifier(): string - { - return 'admin-test'; - } - }; - } - - private function commercialeUser(): UserInterface - { - return new class implements UserInterface, BusinessRoleAwareInterface { - public function hasBusinessRole(string $roleCode): bool - { - return BusinessRoles::COMMERCIALE === $roleCode; - } - - public function getRoles(): array - { - return ['ROLE_USER']; - } - - public function eraseCredentials(): void {} - - public function getUserIdentifier(): string - { - return 'commerciale-test'; - } - }; - } -}