From d72f67d37452ee6927c963806e6d686aa8df7f7e Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:00:55 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(front)=20:=20page=20R=C3=A9pertoire=20?= =?UTF-8?q?fournisseurs=20(/suppliers)=20+=20datatable=20+=20filtres=20+?= =?UTF-8?q?=20export=20(ERP-93)=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page liste `/suppliers` (ERP-93, étape front 6/7 du M2). ## Périmètre Répertoire fournisseurs uniquement (datatable + filtres + export). Les écrans new/consultation/edit sont d'autres tickets. - `MalioDataTable` branché sur `usePaginatedList({url:'/suppliers'})` - Colonnes : Nom, Catégories (`categories[].name`), Site (`sites[].name`, badges colorés), Dernière activité (`updatedAt`) ; clic ligne → `/suppliers/{id}` - Boutons : « + Ajouter » (manage), « Filtrer » (drawer : search / categoryCode / siteId / includeArchived + badge + Réinitialiser), « Exporter » (XLSX) - État filtres/pagination 100 % local (règle n°6) ; pagination 10/25/50 ; `useApi()` + composants `Malio*` only ## Différences vs Répertoire clients - filtre `includeArchived` (au lieu de `archivedOnly`) - colonne Catégories = `name` (clients affiche `code`) - catégories du filtre = `?typeCode=FOURNISSEUR` ; export `/suppliers/export.xlsx` ## Tests - `make nuxt-test` : 284 passed (11 nouveaux : useSuppliersRepository ×3, page index ×8) - ESLint propre ; typecheck sans erreur sur les fichiers suppliers - Golden path navigateur OK (page + drawer) Aucun mirror RBAC à toucher (sidebar + permissions posés par #90/#92). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/81 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 26 ++ .../__tests__/useSuppliersRepository.spec.ts | 85 ++++ .../composables/useSuppliersRepository.ts | 54 +++ .../pages/__tests__/suppliersIndex.spec.ts | 205 +++++++++ .../commercial/pages/suppliers/index.vue | 434 ++++++++++++++++++ 5 files changed, 804 insertions(+) create mode 100644 frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts create mode 100644 frontend/modules/commercial/composables/useSuppliersRepository.ts create mode 100644 frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts create mode 100644 frontend/modules/commercial/pages/suppliers/index.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index bac08d0..9b77a1d 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -49,6 +49,32 @@ "commercial": { "title": "Commercial", "welcome": "Module Commercial", + "suppliers": { + "title": "Répertoire fournisseurs", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun fournisseur pour l'instant.", + "column": { + "companyName": "Nom", + "categories": "Catégories", + "sites": "Site", + "lastActivity": "Dernière activité" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "includeArchived": "Inclure les archivés", + "apply": "Voir les résultats", + "reset": "Réinitialiser" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire fournisseurs a échoué. Réessayez." + } + }, "clients": { "title": "Répertoire clients", "add": "Ajouter", diff --git a/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts new file mode 100644 index 0000000..6d5392c --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useSuppliersRepository.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { HydraCollection } from '~/shared/utils/api' +import type { Supplier } from '../useSuppliersRepository' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe) +// et controler les reponses. Meme pattern que useClientsRepository.spec.ts. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES le stub pour que useApi soit bien resolu au top-level du module. +const { useSuppliersRepository } = await import('../useSuppliersRepository') + +/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */ +function makeHydra(total: number): HydraCollection { + return { totalItems: total, member: [] } +} + +describe('useSuppliersRepository', () => { + beforeEach(() => { + mockGet.mockReset() + // 25 items → 3 pages a 10/page : permet de tester la navigation page 2. + mockGet.mockResolvedValue(makeHydra(25)) + }) + + it('cible la ressource /suppliers en page 1 par defaut', async () => { + const repo = useSuppliersRepository() + await repo.fetch() + + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) + + it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => { + const repo = useSuppliersRepository() + await repo.fetch() + await repo.goToPage(2) + expect(repo.currentPage.value).toBe(2) + + await repo.setFilters( + { + search: 'acme', + 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], + 'siteId[]': ['86', '17'], + includeArchived: true, + }, + { replace: true }, + ) + + expect(repo.currentPage.value).toBe(1) + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { + search: 'acme', + 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], + 'siteId[]': ['86', '17'], + includeArchived: true, + page: 1, + itemsPerPage: 10, + }, + expect.objectContaining({ toast: false }), + ) + }) + + it('repasse a une query propre apres reinitialisation des filtres', async () => { + const repo = useSuppliersRepository() + await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true }) + await repo.setFilters({}, { replace: true }) + + expect(mockGet).toHaveBeenLastCalledWith( + '/suppliers', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) +}) diff --git a/frontend/modules/commercial/composables/useSuppliersRepository.ts b/frontend/modules/commercial/composables/useSuppliersRepository.ts new file mode 100644 index 0000000..5c05b10 --- /dev/null +++ b/frontend/modules/commercial/composables/useSuppliersRepository.ts @@ -0,0 +1,54 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE + * (groupe site:read) pour la colonne « Site » du Repertoire (badges colores). + * Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2). + */ +export interface SupplierSite { + id: number + name: string + color: string +} + +/** + * Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE + * (groupe category:read). La colonne « Catégories » affiche le `name` (et non le + * `code` comme au M1 clients — decision spec-front M2 § Datatable). + */ +export interface SupplierCategory { + code: string + name: string +} + +/** + * Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93). + */ +export interface Supplier { + id: number + companyName: string + categories: SupplierCategory[] + sites: SupplierSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList` + * sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais + * de chargement integral en memoire). Miroir de `useClientsRepository` (M1). + * + * Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes + * par la page via `setFilters` du composable partage — la remise en page 1 est + * garantie. + * + * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau + * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de + * `usePaginatedList`. Aucun reset au logout a gerer. + */ +export function useSuppliersRepository() { + return usePaginatedList({ url: '/suppliers' }) +} diff --git a/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts b/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts new file mode 100644 index 0000000..5ea48bc --- /dev/null +++ b/frontend/modules/commercial/pages/__tests__/suppliersIndex.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref } from 'vue' + +// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── +// La page ne les importe pas (auto-import) : on les expose en globals pour le +// runtime de test (happy-dom). Meme philosophie que les autres specs commercial. +const mockPush = vi.hoisted(() => vi.fn()) +const mockApiGet = vi.hoisted(() => vi.fn()) +const mockCan = vi.hoisted(() => vi.fn()) +const mockSetFilters = vi.hoisted(() => vi.fn()) +const mockFetch = vi.hoisted(() => vi.fn()) +const mockToastError = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useHead', () => undefined) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) +vi.stubGlobal('useRouter', () => ({ push: mockPush })) +vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) +vi.stubGlobal('usePermissions', () => ({ can: mockCan })) + +// Le repository est lui aussi un auto-import : on controle items + setFilters. +vi.stubGlobal('useSuppliersRepository', () => ({ + items: ref([ + { + id: 7, + companyName: 'ACME', + categories: [{ code: 'NEG', name: 'Négociant' }], + sites: [{ id: 86, name: '86', color: '#123456' }], + updatedAt: '2026-01-15T10:00:00+00:00', + }, + ]), + totalItems: ref(1), + currentPage: ref(1), + itemsPerPage: ref(10), + itemsPerPageOptions: ref([10, 25, 50]), + fetch: mockFetch, + goToPage: vi.fn(), + setItemsPerPage: vi.fn(), + setFilters: mockSetFilters, +})) + +// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques +// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse). +globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') +globalThis.URL.revokeObjectURL = vi.fn() + +// Import APRES les stubs (la page resout les auto-imports au top-level du module). +const SuppliersIndex = (await import('../suppliers/index.vue')).default + +// ── Stubs de composants ────────────────────────────────────────────────────── +const ButtonStub = defineComponent({ + props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, + emits: ['click'], + setup(props, { emit }) { + return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) + }, +}) + +const DataTableStub = defineComponent({ + props: { items: { type: Array, default: () => [] } }, + emits: ['row-click', 'update:page', 'update:per-page'], + setup(props, { emit }) { + return () => h('div', { 'data-testid': 'datatable' }, + (props.items as Array<{ id: number }>).map(it => + h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }), + ), + ) + }, +}) + +const DrawerStub = defineComponent({ + props: { modelValue: { type: Boolean, default: false } }, + setup(_, { slots }) { + return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) + }, +}) + +const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } }) + +const PageHeaderStub = defineComponent({ + setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, +}) + +const CheckboxStub = defineComponent({ + props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } }, + emits: ['update:model-value'], + setup(props, { emit }) { + return () => h('input', { + 'type': 'checkbox', + 'data-id': props.id, + 'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked), + }) + }, +}) + +const InputTextStub = defineComponent({ setup() { return () => h('input') } }) + +function mountPage() { + return mount(SuppliersIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + MalioDrawer: DrawerStub, + MalioAccordion: SlotStub, + MalioAccordionItem: SlotStub, + MalioInputText: InputTextStub, + MalioCheckbox: CheckboxStub, + }, + }, + }) +} + +describe('Répertoire fournisseurs (page /suppliers)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockResolvedValue({ member: [] }) + mockCan.mockReset().mockReturnValue(true) + mockSetFilters.mockReset() + mockFetch.mockReset() + mockToastError.mockReset() + }) + + it('charge la liste au montage', async () => { + mountPage() + await flushPromises() + expect(mockFetch).toHaveBeenCalled() + }) + + it('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false) + }) + + it('navigue vers la consultation au clic sur une ligne', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('tr[data-row-id="7"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/suppliers/7') + }) + + it('charge les categories de type FOURNISSEUR pour le filtre', async () => { + mountPage() + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/categories', + expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }), + expect.objectContaining({ toast: false }), + ) + }) + + it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/suppliers/export.xlsx', + expect.any(Object), + expect.objectContaining({ responseType: 'blob', toast: false }), + ) + }) + + it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { + const wrapper = mountPage() + await flushPromises() + + // Coche « Inclure les archivés » puis applique les filtres. + await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) + await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') + + expect(mockSetFilters).toHaveBeenLastCalledWith( + { includeArchived: true }, + { replace: true }, + ) + // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. + expect(mockPush).not.toHaveBeenCalled() + }) + + it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => { + const wrapper = mountPage() + await flushPromises() + + await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) + await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') + + // Le libelle du bouton Filtrer porte le compteur (1 filtre actif). + expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true) + + // Réinitialiser → query propre (setFilters avec objet vide). + await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click') + expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true }) + }) +}) diff --git a/frontend/modules/commercial/pages/suppliers/index.vue b/frontend/modules/commercial/pages/suppliers/index.vue new file mode 100644 index 0000000..ea374ef --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/index.vue @@ -0,0 +1,434 @@ + + + + + From cc7a657df933bf25b849215f33ec70c5ea76b7c9 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 11 Jun 2026 07:01:03 +0000 Subject: [PATCH 2/9] chore: bump version to v0.1.102 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 9439d45..04d279b 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.101' + app.version: '0.1.102' From 1b0339bf1c12d410f29d91165f1792b39e16aaad Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:08:03 +0000 Subject: [PATCH 3/9] =?UTF-8?q?chore(front)=20:=20i18n=20=C3=A9crans/ongle?= =?UTF-8?q?ts=20fournisseurs=20+=20sidebar=20fournisseur=20avant=20client?= =?UTF-8?q?=20(ERP-97)=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERP-97 (étape front 6/7 du M2, parallèle). **Stack sur #93** (MR #81) : base = `feature/ERP-93-suppliers-list` pour un diff isolé. À recibler sur `develop` une fois #93 mergée. ## Périmètre - **Sidebar** : `Répertoire fournisseurs` placé **avant** `Répertoire clients` (`config/sidebar.php`). Affichage conditionnel par `commercial.suppliers.view` (déjà câblé par #90), vérifié au navigateur. - **i18n écrans/onglets** : bloc `commercial.suppliers.*` complété (onglets Information/Contacts/Adresses/Transport/Comptabilité/Statistiques/Rapports/Échanges, titres Consultation/Modification, actions, `comingSoon`, toasts). Scaffolding pour #94/#95/#96. ## Déjà couvert (vérifié, non modifié) - Clé sidebar `sidebar.commercial.suppliers` : déjà présente. - Libellés audit-log `audit.entity.commercial_supplier{,address,contact,rib}` : **déjà présents** (ajoutés côté back avec les entités `#[Auditable]`). Garde-fou `AuditableEntitiesHaveI18nLabelTest` : OK (43 assertions). ## Tests - `make nuxt-test` : 284 passed. - `AuditableEntitiesHaveI18nLabelTest` (isolé) : OK. - Golden path : sidebar fournisseurs au-dessus de clients ✓. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/82 Co-authored-by: tristan Co-committed-by: tristan --- config/sidebar.php | 14 +++++------ frontend/i18n/locales/fr.json | 45 ++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/config/sidebar.php b/config/sidebar.php index f2baf26..9be6d4a 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -45,13 +45,6 @@ return [ 'label' => 'sidebar.commercial.section', 'icon' => 'mdi:account-arrow-left-outline', 'items' => [ - [ - 'label' => 'sidebar.commercial.clients', - 'to' => '/clients', - 'icon' => 'mdi:account-group-outline', - 'module' => 'commercial', - 'permission' => 'commercial.clients.view', - ], [ 'label' => 'sidebar.commercial.suppliers', 'to' => '/suppliers', @@ -59,6 +52,13 @@ return [ 'module' => 'commercial', 'permission' => 'commercial.suppliers.view', ], + [ + 'label' => 'sidebar.commercial.clients', + 'to' => '/clients', + 'icon' => 'mdi:account-group-outline', + 'module' => 'commercial', + 'permission' => 'commercial.clients.view', + ], ], ], // Section "Administration" : regroupe toutes les pages de configuration diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 9b77a1d..b5c6298 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -72,7 +72,50 @@ }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire fournisseurs a échoué. Réessayez." + "exportError": "L'export du répertoire fournisseurs a échoué. Réessayez.", + "createSuccess": "Fournisseur créé avec succès", + "updateSuccess": "Fournisseur mis à jour avec succès", + "addComplete": "Fournisseur ajouté", + "archiveSuccess": "Fournisseur archivé avec succès", + "restoreSuccess": "Fournisseur restauré avec succès", + "restoreConflict": "Impossible de restaurer : un fournisseur actif portant ce nom existe déjà." + }, + "comingSoon": "À venir", + "tab": { + "information": "Information", + "contacts": "Contacts", + "addresses": "Adresses", + "transport": "Transport", + "accounting": "Comptabilité", + "statistics": "Statistiques", + "reports": "Rapports", + "exchanges": "Échanges" + }, + "action": { + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer" + }, + "consultation": { + "title": "Consultation fournisseur", + "back": "Retour au répertoire", + "loading": "Chargement du fournisseur…", + "notFound": "Fournisseur introuvable.", + "confirmArchive": { + "title": "Archiver le fournisseur", + "message": "Ce fournisseur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" + }, + "confirmRestore": { + "title": "Restaurer le fournisseur", + "message": "Ce fournisseur réapparaîtra dans le répertoire actif. Confirmer la restauration ?" + } + }, + "edit": { + "title": "Modifier le fournisseur", + "back": "Retour au répertoire", + "loading": "Chargement du fournisseur…", + "notFound": "Fournisseur introuvable.", + "save": "Valider" } }, "clients": { From 3c1fc39eeeff20af46cc45f372c5d51220afa52f Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 11 Jun 2026 07:10:24 +0000 Subject: [PATCH 4/9] chore: bump version to v0.1.103 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 04d279b..c265dd3 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.102' + app.version: '0.1.103' From d6790dd37d7e31c28f9760e821f455e0a29bfe97 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:14:51 +0000 Subject: [PATCH 5/9] 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'; - } - }; - } -} From 477f77a6b5e6649c269b90e5d9fcd0555c046c6f Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:15:28 +0000 Subject: [PATCH 6/9] feat(front) : page Consultation fournisseur (/suppliers/{id}) lecture seule (ERP-95) (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ERP-95 — Consultation fournisseur (lecture seule) Étape 6/7 (front). Dépend de #92 (contrat JSON figé) et #94 (blocs/types fournisseur). Bloque #96. > ⚠️ MR **stackée sur `feature/ERP-94-suppliers-new`** (ERP-94 pas encore mergée dans develop) pour garder le diff limité aux 5 fichiers d'ERP-95. À recibler sur `develop` une fois la 94 mergée. Squash au merge. ### Périmètre - `useSupplier(id)` : GET /api/suppliers/{id} en Hydra (embed contacts/adresses/ribs + scalaires compta si `accounting.view`), `archive()`/`restore()` via PATCH `isArchived` seul + rechargement complet. - `supplierConsultation` : mappers purs de l'embed (enum `addressType`, `bennes`/`triageProvider`, `volumeForecast`, gating compta par **omission de clé** → null) + helpers de permissions. - Page `[id]/index.vue` lecture seule : bloc principal + onglets Information / Contacts / Adresses / Comptabilité (si permission) / 4 coquilles « À venir » ; boutons Modifier (`manage`), Archiver/Restaurer (`archive`) ; flèche retour → répertoire. Miroir de l'écran Consultation client (M1). ### Tests - Vitest : `supplierConsultation.spec.ts` (mappers + permissions, gating compta) + `useSupplier.spec.ts` (GET/PATCH + propagation 403/409). `make nuxt-test` → 365/365 ✅. ESLint ✅. - `nuxi typecheck` non lancé sur l'hôte (régénère .nuxt/tailwind en chemins hôte et casse le conteneur dev-nuxt). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/84 Co-authored-by: tristan Co-committed-by: tristan --- .../composables/__tests__/useSupplier.spec.ts | 95 ++++ .../commercial/composables/useSupplier.ts | 71 +++ .../commercial/pages/suppliers/[id]/index.vue | 454 ++++++++++++++++++ .../__tests__/supplierConsultation.spec.ts | 224 +++++++++ .../commercial/utils/supplierConsultation.ts | 301 ++++++++++++ 5 files changed, 1145 insertions(+) create mode 100644 frontend/modules/commercial/composables/__tests__/useSupplier.spec.ts create mode 100644 frontend/modules/commercial/composables/useSupplier.ts create mode 100644 frontend/modules/commercial/pages/suppliers/[id]/index.vue create mode 100644 frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts create mode 100644 frontend/modules/commercial/utils/supplierConsultation.ts diff --git a/frontend/modules/commercial/composables/__tests__/useSupplier.spec.ts b/frontend/modules/commercial/composables/__tests__/useSupplier.spec.ts new file mode 100644 index 0000000..c2749e7 --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useSupplier.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom). +const mockGet = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) + +const { useSupplier } = await import('../useSupplier') + +const SAMPLE = { '@id': '/api/suppliers/85', id: 85, companyName: 'DOD59393F 862875', isArchived: false } + +describe('useSupplier', () => { + beforeEach(() => { + mockGet.mockReset() + mockPatch.mockReset() + mockGet.mockResolvedValue(SAMPLE) + mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true }) + }) + + it('charge le detail via GET /suppliers/{id} en Hydra, sans toast', async () => { + const { supplier, load } = useSupplier(85) + await load() + + expect(mockGet).toHaveBeenCalledWith( + '/suppliers/85', + {}, + expect.objectContaining({ + headers: { Accept: 'application/ld+json' }, + toast: false, + }), + ) + expect(supplier.value).toEqual(SAMPLE) + }) + + it('bascule loading pendant le chargement et le retombe a false', async () => { + const { loading, load } = useSupplier(85) + const promise = load() + expect(loading.value).toBe(true) + await promise + expect(loading.value).toBe(false) + }) + + it('marque error et laisse supplier null si le GET echoue (404...)', async () => { + mockGet.mockRejectedValueOnce(new Error('not found')) + const { supplier, error, load } = useSupplier(99) + await load() + expect(error.value).toBe(true) + expect(supplier.value).toBeNull() + }) + + it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => { + // 1er GET = chargement initial, 2e GET = rechargement post-archivage. + mockGet.mockResolvedValueOnce(SAMPLE) + mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true }) + const { supplier, load, archive } = useSupplier(85) + await load() + await archive() + + expect(mockPatch).toHaveBeenCalledWith( + '/suppliers/85', + { isArchived: true }, + expect.objectContaining({ toast: false }), + ) + // Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet). + expect(mockGet).toHaveBeenCalledTimes(2) + expect(supplier.value?.isArchived).toBe(true) + }) + + it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => { + const { load, restore } = useSupplier(85) + await load() + await restore() + + expect(mockPatch).toHaveBeenCalledWith( + '/suppliers/85', + { isArchived: false }, + expect.objectContaining({ toast: false }), + ) + }) + + it('propage l\'erreur (ex: 403 sans permission archive, 409 conflit homonyme) au lieu de l\'avaler', async () => { + const forbidden = { response: { status: 403 } } + mockPatch.mockRejectedValueOnce(forbidden) + const { load, archive } = useSupplier(85) + await load() + await expect(archive()).rejects.toBe(forbidden) + }) +}) diff --git a/frontend/modules/commercial/composables/useSupplier.ts b/frontend/modules/commercial/composables/useSupplier.ts new file mode 100644 index 0000000..47d05a1 --- /dev/null +++ b/frontend/modules/commercial/composables/useSupplier.ts @@ -0,0 +1,71 @@ +import { ref } from 'vue' +import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' + +/** + * Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation + * fournisseur », ERP-95). Miroir de `useClient` (M1). Lit le detail embarque via + * `GET /api/suppliers/{id}` (contacts / adresses / ribs sous `supplier:item:read` / + * `supplier:read:accounting`) et expose les bascules d'archivage (PATCH `isArchived` + * SEUL — tout autre champ => 422). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload + * Hydra complet (sans lui, API Platform 4 renvoie une representation reduite). + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs + * d'archivage/restauration (notamment le 409 d'homonyme actif a la restauration) + * sont PROPAGEES a l'appelant, qui decide du toast a afficher. + */ +export function useSupplier(id: number | string) { + const api = useApi() + + const supplier = ref(null) + const loading = ref(false) + const error = ref(false) + + /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */ + function fetchDetail(): Promise { + return api.get( + `/suppliers/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + /** Charge le detail du fournisseur. En cas d'echec : `error = true`, `supplier = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + supplier.value = await fetchDetail() + } + catch { + error.value = true + supplier.value = null + } + finally { + loading.value = false + } + } + + /** + * Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422), + * puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe + * `supplier:read` (ni l'embed contacts/adresses/ribs ni les libelles des + * referentiels comptables), un simple merge laisserait l'affichage incoherent. + * Toute erreur (notamment le 409 d'homonyme actif a la restauration) est + * propagee a l'appelant AVANT le rechargement. + */ + async function setArchived(isArchived: boolean): Promise { + await api.patch(`/suppliers/${id}`, { isArchived }, { toast: false }) + supplier.value = await fetchDetail() + } + + return { + supplier, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/commercial/pages/suppliers/[id]/index.vue b/frontend/modules/commercial/pages/suppliers/[id]/index.vue new file mode 100644 index 0000000..41c721d --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/[id]/index.vue @@ -0,0 +1,454 @@ + + + diff --git a/frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts new file mode 100644 index 0000000..eb67488 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/supplierConsultation.spec.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest' +import { + canEditSupplier, + categoryOptionsOf, + contactOptionsOf, + iriOf, + mapAccountingDraft, + mapAddressToDraft, + mapAddressView, + mapContactToDraft, + mapRibToDraft, + referentialOptionOf, + showArchiveAction, + showRestoreAction, + siteOptionsOf, + type SupplierDetail, +} from '../supplierConsultation' + +describe('iriOf', () => { + it('retourne l\'@id d\'une relation embarquee (objet)', () => { + expect(iriOf({ '@id': '/api/payment_types/14', code: 'LCR' })).toBe('/api/payment_types/14') + }) + + it('retourne la chaine telle quelle si la relation est deja un IRI', () => { + expect(iriOf('/api/banks/3')).toBe('/api/banks/3') + }) + + it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => { + expect(iriOf(null)).toBeNull() + expect(iriOf(undefined)).toBeNull() + }) +}) + +describe('mapContactToDraft', () => { + it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => { + const draft = mapContactToDraft({ + '@id': '/api/supplier_contacts/39', + id: 39, + firstName: 'Marie', + lastName: 'Martin', + jobTitle: 'Responsable achats', + phonePrimary: '0612345678', + email: 'marie.martin@seed.test', + }) + expect(draft.id).toBe(39) + expect(draft.iri).toBe('/api/supplier_contacts/39') + expect(draft.phonePrimary).toBe('06 12 34 56 78') + expect(draft.hasSecondaryPhone).toBe(false) + }) + + it('revele le 2e telephone quand phoneSecondary est present', () => { + const draft = mapContactToDraft({ + '@id': '/api/supplier_contacts/40', + id: 40, + phonePrimary: '0600000000', + phoneSecondary: '0611111111', + }) + expect(draft.hasSecondaryPhone).toBe(true) + expect(draft.phoneSecondary).toBe('06 11 11 11 11') + }) +}) + +describe('mapAddressToDraft', () => { + it('mappe l\'enum addressType, les champs fournisseur et extrait les iris', () => { + const draft = mapAddressToDraft({ + '@id': '/api/supplier_addresses/33', + id: 33, + addressType: 'DEPART', + country: 'France', + postalCode: '86000', + city: 'Poitiers', + street: '12 rue des Acacias', + bennes: 3, + triageProvider: true, + sites: [{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#056CF2' }], + categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }], + contacts: [{ '@id': '/api/supplier_contacts/39' }, '/api/supplier_contacts/41'], + }) + expect(draft.addressType).toBe('DEPART') + expect(draft.siteIris).toEqual(['/api/sites/87']) + expect(draft.categoryIris).toEqual(['/api/categories/2279']) + expect(draft.contactIris).toEqual(['/api/supplier_contacts/39', '/api/supplier_contacts/41']) + // bennes (entier) → chaine pour MalioInputNumber. + expect(draft.bennes).toBe('3') + expect(draft.triageProvider).toBe(true) + expect(draft.city).toBe('Poitiers') + expect(draft.country).toBe('France') + }) + + it('tolere les champs absents (defauts : France, bennes « 0 », triage faux, type null)', () => { + const draft = mapAddressToDraft({ '@id': '/api/supplier_addresses/9', id: 9 }) + expect(draft.addressType).toBeNull() + expect(draft.siteIris).toEqual([]) + expect(draft.categoryIris).toEqual([]) + expect(draft.contactIris).toEqual([]) + expect(draft.country).toBe('France') + expect(draft.bennes).toBe('0') + expect(draft.triageProvider).toBe(false) + }) +}) + +describe('mapRibToDraft', () => { + it('mappe label / bic / iban et l\'id serveur', () => { + const draft = mapRibToDraft({ '@id': '/api/supplier_ribs/27', id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' }) + expect(draft).toEqual({ id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' }) + }) +}) + +describe('mapAccountingDraft', () => { + it('mappe les scalaires et resout les iris des referentiels embarques', () => { + const acc = mapAccountingDraft({ + '@id': '/api/suppliers/85', + id: 85, + siren: '123456789', + accountNumber: 'F0001', + nTva: 'FR00123456789', + tvaMode: { '@id': '/api/tva_modes/30' }, + paymentDelay: { '@id': '/api/payment_delays/11' }, + paymentType: { '@id': '/api/payment_types/14', code: 'LCR' }, + bank: { '@id': '/api/banks/3' }, + } as SupplierDetail) + expect(acc).toEqual({ + siren: '123456789', + accountNumber: 'F0001', + nTva: 'FR00123456789', + tvaModeIri: '/api/tva_modes/30', + paymentDelayIri: '/api/payment_delays/11', + paymentTypeIri: '/api/payment_types/14', + bankIri: '/api/banks/3', + }) + }) + + it('renvoie des null quand les champs comptables sont absents (gating par omission, sans accounting.view)', () => { + const acc = mapAccountingDraft({} as SupplierDetail) + expect(acc).toEqual({ + siren: null, + accountNumber: null, + nTva: null, + tvaModeIri: null, + paymentDelayIri: null, + paymentTypeIri: null, + bankIri: null, + }) + }) +}) + +describe('options construites depuis l\'embed (role-independantes)', () => { + it('categoryOptionsOf expose value=IRI, label=nom, code', () => { + expect(categoryOptionsOf([{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }])).toEqual([ + { value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }, + ]) + }) + + it('siteOptionsOf expose value=IRI, label=nom', () => { + expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([ + { value: '/api/sites/87', label: 'Chatellerault' }, + ]) + }) + + it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => { + expect(contactOptionsOf([ + { '@id': '/api/supplier_contacts/1', id: 1, firstName: 'Marie', lastName: 'Martin' }, + { '@id': '/api/supplier_contacts/2', id: 2, email: 'a@b.fr' }, + ])).toEqual([ + { value: '/api/supplier_contacts/1', label: 'Marie Martin' }, + { value: '/api/supplier_contacts/2', label: 'a@b.fr' }, + ]) + }) + + it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => { + expect(referentialOptionOf({ '@id': '/api/payment_types/14', label: 'LCR' })).toEqual([ + { value: '/api/payment_types/14', label: 'LCR' }, + ]) + expect(referentialOptionOf('/api/banks/3')).toEqual([]) + expect(referentialOptionOf(null)).toEqual([]) + }) + + it('mapAddressView assemble brouillon + options propres a l\'adresse', () => { + const view = mapAddressView({ + '@id': '/api/supplier_addresses/33', + id: 33, + addressType: 'RENDU', + city: 'Poitiers', + sites: [{ '@id': '/api/sites/87', name: 'Chatellerault' }], + categories: [{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }], + }) + expect(view.draft.id).toBe(33) + expect(view.draft.addressType).toBe('RENDU') + expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }]) + expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }]) + }) +}) + +describe('canEditSupplier', () => { + const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c)) + + it('visible pour manage', () => { + expect(canEditSupplier(can(['commercial.suppliers.manage']))).toBe(true) + }) + + it('visible pour accounting.manage (role Compta)', () => { + expect(canEditSupplier(can(['commercial.suppliers.accounting.manage']))).toBe(true) + }) + + it('masque sans aucune des deux permissions (role Usine)', () => { + expect(canEditSupplier(can(['commercial.suppliers.view']))).toBe(false) + }) +}) + +describe('showArchiveAction / showRestoreAction', () => { + const can = (granted: string[]) => (code: string) => granted.includes(code) + + it('Archiver : visible avec la permission archive ET fournisseur non archive', () => { + expect(showArchiveAction(can(['commercial.suppliers.archive']), false)).toBe(true) + expect(showArchiveAction(can(['commercial.suppliers.archive']), true)).toBe(false) + expect(showArchiveAction(can([]), false)).toBe(false) + }) + + it('Restaurer : visible avec la permission archive ET fournisseur archive', () => { + expect(showRestoreAction(can(['commercial.suppliers.archive']), true)).toBe(true) + expect(showRestoreAction(can(['commercial.suppliers.archive']), false)).toBe(false) + expect(showRestoreAction(can([]), true)).toBe(false) + }) +}) diff --git a/frontend/modules/commercial/utils/supplierConsultation.ts b/frontend/modules/commercial/utils/supplierConsultation.ts new file mode 100644 index 0000000..9264b61 --- /dev/null +++ b/frontend/modules/commercial/utils/supplierConsultation.ts @@ -0,0 +1,301 @@ +/** + * Helpers purs de l'ecran « Consultation fournisseur » (M2 Commercial, lecture + * seule). Miroir de `clientConsultation.ts` (M1), adapte aux differences M2. + * + * Mappent le payload `GET /api/suppliers/{id}` (relations embarquees, cf. groupe + * `supplier:item:read` + `supplier:read:accounting`) vers les brouillons « plats » + * partages avec les blocs reutilisables `SupplierContactBlock` / `SupplierAddressBlock` + * et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables + * unitairement (cf. supplierConsultation.spec.ts). + * + * Rappels de contrat back (verifies sur le JSON reel fige — ERP-92, spec-back § 4.0.bis) : + * - les relations ManyToOne (tvaMode/paymentDelay/paymentType/bank) sont + * serialisees en OBJETS embarques (`{id, code, label}`), pas en IRI nu ; + * - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ; + * - les champs comptables et `ribs` sont TOTALEMENT ABSENTS (cle omise, pas `null`) + * sans permission accounting.view (gate serveur via SupplierReadGroupContextBuilder). + * + * Differences M2 vs M1 : + * - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de + * drapeaux isProspect/isDelivery/isBilling. + * - Adresse : champs specifiques fournisseur `bennes` (nombre) et `triageProvider`. + * Pas d'email de facturation. + * - Information : champ specifique fournisseur `volumeForecast`. + * - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal. + */ + +import { formatPhoneFR } from '~/shared/utils/phone' +import { + emptyAddress, + type SupplierAddressFormDraft, + type SupplierAddressType, + type SupplierContactFormDraft, + type SupplierRibFormDraft, +} from '~/modules/commercial/types/supplierForm' + +/** Reference Hydra embarquee minimale (@id toujours present). */ +export interface HydraRef { + '@id': string + [key: string]: unknown +} + +/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */ +export type Relation = HydraRef | string | null | undefined + +/** Site embarque dans une adresse (groupe site:read). */ +export interface SiteRead extends HydraRef { + name?: string + color?: string +} + +/** Categorie embarquee (groupe category:read). */ +export interface CategoryRead extends HydraRef { + code?: string + name?: string +} + +/** Contact embarque (groupe supplier_contact:read). */ +export interface ContactRead extends HydraRef { + id: number + firstName?: string | null + lastName?: string | null + jobTitle?: string | null + phonePrimary?: string | null + phoneSecondary?: string | null + email?: string | null +} + +/** Adresse embarquee (groupe supplier_address:read). */ +export interface AddressRead extends HydraRef { + id: number + addressType?: SupplierAddressType | null + country?: string | null + postalCode?: string | null + city?: string | null + street?: string | null + streetComplement?: string | null + bennes?: number | null + triageProvider?: boolean + sites?: SiteRead[] + categories?: CategoryRead[] + // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. + contacts?: Array +} + +/** RIB embarque (groupe supplier:read:accounting, present ssi accounting.view). */ +export interface RibRead extends HydraRef { + id: number + label?: string | null + bic?: string | null + iban?: string | null +} + +/** + * Detail d'un fournisseur tel que renvoye par `GET /api/suppliers/{id}`. Tous les + * champs sont optionnels : skip_null_values cote serveur et gating accounting + * peuvent omettre n'importe quelle cle. + */ +export interface SupplierDetail extends HydraRef { + id: number + companyName?: string | null + isArchived?: boolean + categories?: CategoryRead[] + contacts?: ContactRead[] + addresses?: AddressRead[] + ribs?: RibRead[] + // Onglet Information + description?: string | null + competitors?: string | null + foundedAt?: string | null + employeesCount?: number | null + revenueAmount?: string | null + profitAmount?: string | null + directorName?: string | null + /** Volume previsionnel (entier, specifique fournisseur). */ + volumeForecast?: number | null + // Onglet Comptabilite (present ssi accounting.view) + siren?: string | null + accountNumber?: string | null + nTva?: string | null + tvaMode?: Relation + paymentDelay?: Relation + paymentType?: Relation + bank?: Relation +} + +/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire). */ +export interface AccountingDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + bankIri: string | null +} + +/** Option de select ({ value, label }) construite a partir de l'embed. */ +export interface SelectOption { + value: string + label: string +} + +/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */ +export interface CategorySelectOption extends SelectOption { + code: string +} + +/** + * Vue d'une adresse pour la consultation : le brouillon + ses options de select + * construites a partir de l'embed (sites/categories propres a CETTE adresse). + */ +export interface AddressView { + draft: SupplierAddressFormDraft + siteOptions: SelectOption[] + categoryOptions: CategorySelectOption[] +} + +/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */ +export function iriOf(relation: Relation): string | null { + if (relation === null || relation === undefined) { + return null + } + if (typeof relation === 'string') { + return relation + } + return relation['@id'] ?? null +} + +/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ +export function mapContactToDraft(contact: ContactRead): SupplierContactFormDraft { + const phoneSecondary = contact.phoneSecondary ?? null + return { + id: contact.id, + iri: contact['@id'] ?? null, + firstName: contact.firstName ?? null, + lastName: contact.lastName ?? null, + jobTitle: contact.jobTitle ?? null, + phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + email: contact.email ?? null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + } +} + +/** + * Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). + * `bennes` (entier) est converti en chaine pour MalioInputNumber (defaut « 0 »). + */ +export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraft { + return { + id: address.id, + addressType: address.addressType ?? null, + country: address.country ?? 'France', + postalCode: address.postalCode ?? null, + city: address.city ?? null, + street: address.street ?? null, + streetComplement: address.streetComplement ?? null, + categoryIris: (address.categories ?? []).map(c => c['@id']), + siteIris: (address.sites ?? []).map(s => s['@id']), + contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), + bennes: address.bennes != null ? String(address.bennes) : '0', + triageProvider: address.triageProvider ?? false, + } +} + +/** Mappe un RIB embarque vers un brouillon. */ +export function mapRibToDraft(rib: RibRead): SupplierRibFormDraft { + return { + id: rib.id, + label: rib.label ?? null, + bic: rib.bic ?? null, + iban: rib.iban ?? null, + } +} + +/** Mappe les champs comptables du fournisseur (scalaires + IRI des referentiels). */ +export function mapAccountingDraft(supplier: SupplierDetail): AccountingDraft { + return { + siren: supplier.siren ?? null, + accountNumber: supplier.accountNumber ?? null, + nTva: supplier.nTva ?? null, + tvaModeIri: iriOf(supplier.tvaMode), + paymentDelayIri: iriOf(supplier.paymentDelay), + paymentTypeIri: iriOf(supplier.paymentType), + bankIri: iriOf(supplier.bank), + } +} + +/** + * Options de categories (value=IRI, label=nom, code) construites depuis l'embed. + * Source role-independante : evite de dependre de `GET /categories` (403 pour les + * roles metier non-admin), qui laisserait les libelles vides. + */ +export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] { + return (categories ?? []).map(c => ({ + value: c['@id'], + label: c.name ?? c.code ?? c['@id'], + code: c.code ?? '', + })) +} + +/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */ +export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] { + return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] })) +} + +/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */ +export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] { + return (contacts ?? []).map(c => ({ + value: c['@id'], + label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']), + })) +} + +/** + * Liste a une seule option (ou vide) construite depuis un referentiel embarque + * (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en + * lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un + * `GET` de referentiel — l'affichage reste correct quel que soit le role. + */ +export function referentialOptionOf(relation: Relation): SelectOption[] { + if (!relation || typeof relation === 'string') { + return [] + } + const label = (relation.label as string | undefined) + ?? (relation.name as string | undefined) + ?? relation['@id'] + return [{ value: relation['@id'], label }] +} + +/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ +export function mapAddressView(address: AddressRead): AddressView { + return { + draft: mapAddressToDraft(address), + siteOptions: siteOptionsOf(address.sites), + categoryOptions: categoryOptionsOf(address.categories), + } +} + +/** + * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet + * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta + * doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin + * par onglet est gere sur l'ecran d'edition (96). + */ +export function canEditSupplier(canAny: (codes: string[]) => boolean): boolean { + return canAny(['commercial.suppliers.manage', 'commercial.suppliers.accounting.manage']) +} + +/** Bouton « Archiver » : permission archive ET fournisseur encore actif. */ +export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('commercial.suppliers.archive') && !isArchived +} + +/** Bouton « Restaurer » : permission archive ET fournisseur deja archive. */ +export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('commercial.suppliers.archive') && isArchived +} + +/** Brouillon d'adresse vierge (reexport pour la page : 1 bloc vide si aucune adresse). */ +export { emptyAddress } From 59bae8c5e600472123258422e30dd7d2f8c7d6e5 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 11 Jun 2026 07:17:24 +0000 Subject: [PATCH 7/9] chore: bump version to v0.1.105 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index c265dd3..bb43378 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.103' + app.version: '0.1.105' From c594a76d478d913ea8c3ca83fce566dde5b2e66b Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 07:26:32 +0000 Subject: [PATCH 8/9] feat(front) : page Modification fournisseur (/suppliers/{id}/edit) (ERP-96) (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ERP-96 — Modification fournisseur Étape 7/7 (front). Dépend de #94 (Ajouter) + #95 (Consultation). > ⚠️ MR **stackée sur `feature/ERP-95-suppliers-show`** (95 → 94, pas encore mergées dans develop) pour limiter le diff aux 3 fichiers d'ERP-96. À recibler sur `develop` une fois 94 puis 95 mergées. Squash au merge. ### Périmètre - Route `/suppliers/{id}/edit` : champs **pré-remplis** depuis GET /suppliers/{id}, **PATCH partiel indépendant par onglet**. Bloc principal conservé (éditable via son propre PATCH `supplier:write:main`), pas de contact inline (ERP-106). - **Mode strict (RG-2.16)** : chaque onglet n'envoie QUE les champs de son groupe de sérialisation (jamais de mélange → sinon 403). Builders de payload scopés (`supplierEdit`). - Éditabilité par rôle (`resolveTabEditability`) : métier readonly sans `manage` ; Comptabilité visible/éditable selon `accounting.view`/`accounting.manage` ; placeholders non éditables. - Collections contacts/adresses/RIB : POST/PATCH par ligne + DELETE différé des retraits ; 422 mappées **inline par champ** (`propertyPath` → `useSupplierFormErrors`/`extractApiViolations`), jamais un toast fourre-tout (ERP-101). ### Tests - Vitest : `supplierEdit.spec.ts` enrichi (mappers d'hydratation `mapMainDraft`/`mapInformationDraft` avec `volumeForecast`/`mapAccountingFormDraft` + `resolveTabEditability` matrice § 2.7). `make nuxt-test` → 375/375 ✅. ESLint ✅. - `nuxi typecheck` non lancé sur l'hôte (casse le conteneur dev-nuxt). Miroir de l'écran Modification client (M1), adapté M2 (enum `addressType`, `bennes`/`triageProvider`/`volumeForecast`, pas de relation Distributeur/Courtier). Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/85 Co-authored-by: tristan Co-committed-by: tristan --- .../commercial/pages/clients/[id]/edit.vue | 28 +- .../modules/commercial/pages/clients/new.vue | 15 +- .../commercial/pages/suppliers/[id]/edit.vue | 927 ++++++++++++++++++ .../commercial/pages/suppliers/new.vue | 10 +- .../utils/__tests__/clientEdit.spec.ts | 32 + .../utils/__tests__/supplierEdit.spec.ts | 105 +- .../modules/commercial/utils/clientEdit.ts | 47 +- .../commercial/utils/clientFormRules.ts | 25 + .../modules/commercial/utils/supplierEdit.ts | 140 ++- .../commercial/utils/supplierFormRules.ts | 25 + 10 files changed, 1305 insertions(+), 49 deletions(-) create mode 100644 frontend/modules/commercial/pages/suppliers/[id]/edit.vue diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index dc043ce..74f22d7 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -303,7 +303,7 @@ class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" > { mainSubmitting.value = true mainErrors.clearErrors() try { - const updated = await api.patch(`/clients/${clientId}`, buildMainPayload(main), { + const updated = await api.patch(`/clients/${clientId}`, buildMainPayload(main, { forUpdate: true }), { headers: { Accept: 'application/ld+json' }, toast: false, }) @@ -859,7 +859,10 @@ async function submitAddresses(): Promise { addresses.value, addressErrors, async (address) => { - const body = buildAddressPayload(address, isBillingEmailRequired(address)) + // Edition d'une adresse existante : champ requis vide envoye en `''` + // (NotBlank 422) au lieu d'etre omis — sinon le PATCH garderait + // l'ancienne valeur (faux 200). Creation (id null) : omit classique. + const body = buildAddressPayload(address, isBillingEmailRequired(address), { forUpdate: address.id !== null }) if (address.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/addresses`, @@ -950,13 +953,18 @@ async function submitAccounting(): Promise { try { // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. - // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. - // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. + // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : + // sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la + // soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE + // echouer en « dernier RIB d'une LCR » (message plat sans propertyPath). + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, async (rib) => { - const body = buildRibPayload(rib) + // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank + // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). + const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) if (rib.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/ribs`, @@ -970,10 +978,10 @@ async function submitAccounting(): Promise { } }, error => showError(error), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - rib => rib.id === null && isRibBlank(rib), + // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB + // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank + // inline (sinon la modif serait perdue en silence avec un faux toast succes). + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 56d7297..67be80f 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -302,7 +302,7 @@ > { try { // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. - // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. - // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. + // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : + // sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline. + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, @@ -941,10 +942,10 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - // On ne saute QUE les amorces neuves (id null) totalement vides. Un - // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif - // serait perdue en silence avec un faux toast de succes). - rib => rib.id === null && isRibBlank(rib), + // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB + // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank + // inline (sinon la modif serait perdue en silence avec un faux toast succes). + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue new file mode 100644 index 0000000..8936e8c --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -0,0 +1,927 @@ + + + diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue index 9f7ab4b..3101184 100644 --- a/frontend/modules/commercial/pages/suppliers/new.vue +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -266,7 +266,7 @@ class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" > { tabSubmitting.value = true accountingErrors.clearErrors() try { - // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). Seuls les blocs - // RIB TOTALEMENT vides (amorce neuve) sont ignores. + // 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une + // amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans + // aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline. + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, @@ -802,7 +804,7 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }), - rib => rib.id === null && isRibBlank(rib), + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index 60bf4ac..9a6d244 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -211,6 +211,38 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { }) }) +// Bug edition : en PATCH (merge), une cle de champ requis OMISE laisse la valeur +// serveur inchangee -> faux 200 quand l'utilisateur vide le champ. En `forUpdate`, +// on envoie `''` (chaine valide, pas de 400 de type) -> NotBlank 422 inline. +describe('forUpdate (EDITION/PATCH) : champ requis vide -> `\'\'` au lieu d\'etre omis', () => { + it('buildMainPayload : companyName vide envoye en `\'\'`', () => { + const payload = buildMainPayload(mainDraft({ companyName: '' }), { forUpdate: true }) + expect('companyName' in payload).toBe(true) + expect(payload.companyName).toBe('') + }) + + it('buildAddressPayload : postalCode / city / street vides envoyes en `\'\'`', () => { + const address: AddressFormDraft = { + id: 7, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France', + postalCode: '', city: null, street: '1 rue X', streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false, + } + const payload = buildAddressPayload(address, false, { forUpdate: true }) + expect(payload.postalCode).toBe('') + expect(payload.city).toBe('') + // Un champ requis renseigne reste tel quel. + expect(payload.street).toBe('1 rue X') + }) + + it('buildRibPayload : label / bic vides envoyes en `\'\'`, iban conserve', () => { + const payload = buildRibPayload({ id: 4, label: '', bic: null, iban: 'FR7612345' }, { forUpdate: true }) + expect(payload.label).toBe('') + expect(payload.bic).toBe('') + expect(payload.iban).toBe('FR7612345') + }) +}) + describe('mapMainDraft — pre-remplissage bloc principal', () => { it('resout la relation et extrait les IRI (sans contact inline)', () => { const client = { diff --git a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts index 11b2570..8d9f3d6 100644 --- a/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -6,7 +6,12 @@ import { buildInformationPayload, buildMainPayload, buildRibPayload, + mapAccountingFormDraft, + mapInformationDraft, + mapMainDraft, + resolveTabEditability, } from '../supplierEdit' +import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm' describe('buildMainPayload (groupe supplier:write:main)', () => { @@ -17,11 +22,17 @@ describe('buildMainPayload (groupe supplier:write:main)', () => { }) }) - it('omet companyName vide (-> 422 NotBlank, ERP-119)', () => { + it('CREATION : omet companyName vide (-> 422 NotBlank, ERP-119)', () => { const payload = buildMainPayload({ companyName: null, categoryIris: [] }) expect('companyName' in payload).toBe(false) expect(payload.categories).toEqual([]) }) + + it('EDITION (forUpdate) : companyName vide envoye en `\'\'` (PATCH -> 422 NotBlank, pas un faux 200)', () => { + const payload = buildMainPayload({ companyName: '', categoryIris: [] }, { forUpdate: true }) + expect('companyName' in payload).toBe(true) + expect(payload.companyName).toBe('') + }) }) describe('buildInformationPayload (groupe supplier:write:information)', () => { @@ -86,6 +97,16 @@ describe('buildAddressPayload (sous-ressource supplier_address — specificites expect('addressType' in payload).toBe(false) }) + it('EDITION (forUpdate) : un champ requis vide est envoye en `\'\'` (et NON omis) pour declencher la 422 NotBlank au PATCH', () => { + // Bug edition : omettre la cle d'un champ requis vide laisse le PATCH garder + // l'ancienne valeur (faux 200). En `forUpdate`, on envoie `''` -> NotBlank 422. + const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART', postalCode: '' }, { forUpdate: true }) + expect('postalCode' in payload).toBe(true) + expect(payload.postalCode).toBe('') + // Un champ requis renseigne reste tel quel. + expect(payload.addressType).toBe('DEPART') + }) + it('n\'expose jamais d\'email de facturation (difference M1)', () => { const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' }) expect('billingEmail' in payload).toBe(false) @@ -113,3 +134,85 @@ describe('buildRibPayload (sous-ressource supplier_rib)', () => { expect(payload.iban).toBe('FR1420041010050500013M02606') }) }) + +describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => { + it('extrait companyName et les IRI de categories', () => { + const draft = mapMainDraft({ + '@id': '/api/suppliers/85', id: 85, + companyName: 'DOD862875', + categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }], + } as SupplierDetail) + expect(draft.companyName).toBe('DOD862875') + expect(draft.categoryIris).toEqual(['/api/categories/2279']) + }) + + it('gere les cles omises (skip_null_values) sans planter', () => { + const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail) + expect(draft.companyName).toBeNull() + expect(draft.categoryIris).toEqual([]) + }) +}) + +describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => { + it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => { + const draft = mapInformationDraft({ + '@id': '/api/suppliers/85', id: 85, + foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000, + } as SupplierDetail) + expect(draft.foundedAt).toBe('2008-04-01') + expect(draft.employeesCount).toBe('42') + expect(draft.volumeForecast).toBe('8000') + }) + + it('cles omises -> null (volumeForecast inclus)', () => { + const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail) + expect(draft.foundedAt).toBeNull() + expect(draft.employeesCount).toBeNull() + expect(draft.volumeForecast).toBeNull() + expect(draft.description).toBeNull() + }) +}) + +describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => { + it('extrait les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingFormDraft({ + '@id': '/api/suppliers/85', id: 85, + siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789', + tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' }, + paymentType: '/api/payment_types/14', + } as SupplierDetail) + expect(draft.siren).toBe('123456789') + expect(draft.tvaModeIri).toBe('/api/tva_modes/30') + expect(draft.paymentTypeIri).toBe('/api/payment_types/14') + expect(draft.bankIri).toBeNull() + }) + + it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => { + const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail) + expect(draft.siren).toBeNull() + expect(draft.tvaModeIri).toBeNull() + expect(draft.bankIri).toBeNull() + }) +}) + +describe('resolveTabEditability — gating par role (matrice § 2.7)', () => { + it('Admin : tout editable', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true }) + }) + + it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false }) + }) + + it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true }) + }) + + it('Sans permission d\'edition : rien d\'editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false }) + }) +}) diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index 080f112..a0d4dff 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -23,6 +23,7 @@ import { } from '~/modules/commercial/utils/clientConsultation' import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, @@ -139,12 +140,35 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf // ── Scoping strict des payloads PATCH ──────────────────────────────────────── +/** + * Options de construction d'un payload d'ecriture. + * - `forUpdate: false` (defaut, CREATION/POST) : champs requis vides OMIS -> 422 + * NotBlank (le back ne reçoit pas la cle, la propriete garde son defaut). + * - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : champs requis vides + * envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur serveur + * inchangee, faux 200 — cf. blankEmptyRequired). + */ +export interface BuildPayloadOptions { + forUpdate?: boolean +} + +/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */ +function finalizeRequired>( + payload: T, + requiredKeys: readonly string[], + options: BuildPayloadOptions, +): T { + return options.forUpdate + ? blankEmptyRequired(payload, requiredKeys) + : omitEmptyRequired(payload, requiredKeys) +} + /** * Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne * que la FK correspondant au type choisi, l'autre est forcee a null. */ -export function buildMainPayload(main: MainFormDraft): Record { +export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record { // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). // relationType : champ transitoire (non persiste cote back) qui porte // l'intention UI « ce client depend d'un distributeur / courtier ». Il sert @@ -152,14 +176,14 @@ export function buildMainPayload(main: MainFormDraft): Record { // la FK correspondante devient obligatoire -> 422 sur distributor / broker. // Sans equivalent derivable cote back (FK nullable), c'est la seule facon de // rester sur « on soumet, le back tranche » plutot qu'une garde front-only. - return omitEmptyRequired({ + return finalizeRequired({ companyName: main.companyName, categories: main.categoryIris, relationType: main.relationType, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, - }, MAIN_REQUIRED_NON_NULLABLE_KEYS) + }, MAIN_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ @@ -211,9 +235,10 @@ export function buildContactPayload(contact: ContactFormDraft): Record { - // postalCode / city / street omis si vides -> 422 NotBlank (ERP-119). - return omitEmptyRequired({ + // postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119). + return finalizeRequired({ isProspect: address.isProspect, isDelivery: address.isDelivery, isBilling: address.isBilling, @@ -229,18 +254,18 @@ export function buildAddressPayload( contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, - }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload d'un RIB (sous-ressource client_rib). */ -export function buildRibPayload(rib: RibFormDraft): Record { - // label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type - // sur un RIB partiel (ex. IBAN seul). ERP-119. - return omitEmptyRequired({ +export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record { + // label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu + // d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119. + return finalizeRequired({ label: rib.label, bic: rib.bic, iban: rib.iban, - }, RIB_REQUIRED_NON_NULLABLE_KEYS) + }, RIB_REQUIRED_NON_NULLABLE_KEYS, options) } // ── Gating par permission ──────────────────────────────────────────────────── diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 2a26c9f..6db346a 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -419,3 +419,28 @@ export function omitEmptyRequired>( return payload } + +/** + * Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises + * laissees vides par une chaine vide `''` au lieu de les OMETTRE. + * + * Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une + * cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider + * renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant + * `''` (chaine valide), on evite le 400 de type (« must be string, NULL given ») et + * le Validator `NotBlank(trim)` rejette la valeur -> 422 avec propertyPath, mappee + * inline sous le champ. Mute et retourne le payload. + */ +export function blankEmptyRequired>( + payload: T, + requiredKeys: readonly string[], +): T { + for (const key of requiredKeys) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + (payload as Record)[key] = '' + } + } + + return payload +} diff --git a/frontend/modules/commercial/utils/supplierEdit.ts b/frontend/modules/commercial/utils/supplierEdit.ts index a0f793a..2f8a5e5 100644 --- a/frontend/modules/commercial/utils/supplierEdit.ts +++ b/frontend/modules/commercial/utils/supplierEdit.ts @@ -1,19 +1,24 @@ /** - * Helpers purs de payload de l'ecran « Ajouter un fournisseur » (M2 Commercial), - * partages avec la future modification (96) — miroir de `clientEdit.ts` (M1). + * Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2 + * Commercial) — miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux + * testables unitairement (cf. supplierEdit.spec.ts) : + * 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed + + * scalaires) vers les brouillons « plats » edites par la page de modification. + * 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : 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). * - * 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. + * Ces helpers ne touchent ni a l'API ni a l'etat reactif. */ import { ADDRESS_REQUIRED_NON_NULLABLE_KEYS, + blankEmptyRequired, MAIN_REQUIRED_NON_NULLABLE_KEYS, omitEmptyRequired, RIB_REQUIRED_NON_NULLABLE_KEYS, } from '~/modules/commercial/utils/supplierFormRules' +import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import type { SupplierAddressFormDraft, SupplierContactFormDraft, @@ -53,15 +58,118 @@ export interface AccountingFormDraft { bankIri: string | null } +/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */ +export interface SupplierEditAbilities { + /** `commercial.suppliers.manage` : bloc principal + onglets metier. */ + canManage: boolean + /** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */ + canAccountingView: boolean + /** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */ + canAccountingManage: boolean +} + +/** Editabilite resolue par zone d'onglet (deduite des permissions). */ +export interface TabEditability { + /** Bloc principal + onglets Information / Contacts / Adresses editables. */ + businessEditable: boolean + /** Onglet Comptabilite present (affiche). */ + accountingVisible: boolean + /** Onglet Comptabilite editable. */ + accountingEditable: boolean +} + +// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── + +/** Mappe le detail fournisseur vers le brouillon du bloc principal. */ +export function mapMainDraft(supplier: SupplierDetail): MainFormDraft { + return { + companyName: supplier.companyName ?? null, + categoryIris: (supplier.categories ?? []).map(c => c['@id']), + } +} + +/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */ +export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft { + return { + description: supplier.description ?? null, + competitors: supplier.competitors ?? null, + // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. + foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null, + employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null, + revenueAmount: supplier.revenueAmount ?? null, + profitAmount: supplier.profitAmount ?? null, + directorName: supplier.directorName ?? null, + // Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie. + volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null, + } +} + +/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */ +export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft { + return { + siren: supplier.siren ?? null, + accountNumber: supplier.accountNumber ?? null, + nTva: supplier.nTva ?? null, + tvaModeIri: iriOf(supplier.tvaMode), + paymentDelayIri: iriOf(supplier.paymentDelay), + paymentTypeIri: iriOf(supplier.paymentType), + bankIri: iriOf(supplier.bank), + } +} + +/** + * Resout l'editabilite par zone a partir des permissions (option 1 ERP-74, + * miroir UI du re-gating champ-par-champ du SupplierProcessor) : + * - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ; + * - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`. + * + * Produit le comportement attendu : + * - Admin : tout editable. + * - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee. + * - Compta (accounting seul, sans manage) : metier readonly, Compta editable. + */ +export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability { + return { + businessEditable: abilities.canManage, + accountingVisible: abilities.canAccountingView, + accountingEditable: abilities.canAccountingManage, + } +} + +// ── Scoping strict des payloads PATCH/POST ────────────────────────────────── + +/** + * Options de construction d'un payload d'ecriture. + * - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS + * -> 422 NotBlank a l'insert (le back ne reçoit pas la cle). + * - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis + * vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur + * serveur inchangee, faux 200 — cf. blankEmptyRequired). + */ +export interface BuildPayloadOptions { + forUpdate?: boolean +} + +/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */ +function finalizeRequired>( + payload: T, + requiredKeys: readonly string[], + options: BuildPayloadOptions, +): T { + return options.forUpdate + ? blankEmptyRequired(payload, requiredKeys) + : omitEmptyRequired(payload, requiredKeys) +} + /** * Payload du bloc principal — groupe supplier:write:main UNIQUEMENT. - * companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119). + * companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119). */ -export function buildMainPayload(main: MainFormDraft): Record { - return omitEmptyRequired({ +export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ companyName: main.companyName, categories: main.categoryIris, - }, MAIN_REQUIRED_NON_NULLABLE_KEYS) + }, MAIN_REQUIRED_NON_NULLABLE_KEYS, options) } /** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */ @@ -116,8 +224,8 @@ export function buildContactPayload(contact: SupplierContactFormDraft): Record { - return omitEmptyRequired({ +export function buildAddressPayload(address: SupplierAddressFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ addressType: address.addressType, country: address.country, postalCode: address.postalCode || null, @@ -129,14 +237,14 @@ export function buildAddressPayload(address: SupplierAddressFormDraft): Record { - return omitEmptyRequired({ +export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record { + return finalizeRequired({ label: rib.label, bic: rib.bic, iban: rib.iban, - }, RIB_REQUIRED_NON_NULLABLE_KEYS) + }, RIB_REQUIRED_NON_NULLABLE_KEYS, options) } diff --git a/frontend/modules/commercial/utils/supplierFormRules.ts b/frontend/modules/commercial/utils/supplierFormRules.ts index a2cbc75..5f6a505 100644 --- a/frontend/modules/commercial/utils/supplierFormRules.ts +++ b/frontend/modules/commercial/utils/supplierFormRules.ts @@ -217,3 +217,28 @@ export function omitEmptyRequired>( return payload } + +/** + * Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises + * laissees vides par une chaine vide `''` au lieu de les OMETTRE. + * + * Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une + * cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider + * renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant + * `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement + * a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette + * -> 422 avec propertyPath, mappee inline sous le champ. Mute et retourne le payload. + */ +export function blankEmptyRequired>( + payload: T, + requiredKeys: readonly string[], +): T { + for (const key of requiredKeys) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + (payload as Record)[key] = '' + } + } + + return payload +} From c1ce940c98c3899f834f1778e3f81b03d2c98f85 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 11 Jun 2026 07:27:54 +0000 Subject: [PATCH 9/9] chore: bump version to v0.1.106 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index bb43378..f31c3e4 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.105' + app.version: '0.1.106'