From 79d389834b00c1fdfe8dc81854b807bed9b66fc1 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:05:56 +0200 Subject: [PATCH 1/6] =?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)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 @@ + + + + + -- 2.39.5 From 639cf8482f8d460fbfcd9a9861e72cfc6dc6de7d Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:15:16 +0200 Subject: [PATCH 2/6] =?UTF-8?q?chore(front)=20:=20i18n=20=C3=A9crans/ongle?= =?UTF-8?q?ts=20fournisseurs=20+=20fournisseur=20avant=20client=20dans=20l?= =?UTF-8?q?a=20sidebar=20(ERP-97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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": { -- 2.39.5 From 01a3bd6419ba5e5283c3d1543adc5f0aa69c304a Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:37:30 +0200 Subject: [PATCH 3/6] feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94) --- frontend/i18n/locales/fr.json | 75 ++ .../components/SupplierAddressBlock.vue | 327 +++++++ .../components/SupplierContactBlock.vue | 104 +++ .../__tests__/SupplierAddressBlock.spec.ts | 173 ++++ .../__tests__/SupplierContactBlock.spec.ts | 56 ++ .../__tests__/useSupplierReferentials.spec.ts | 63 ++ .../composables/useSupplierFormErrors.ts | 88 ++ .../composables/useSupplierReferentials.ts | 118 +++ .../commercial/pages/suppliers/new.vue | 862 ++++++++++++++++++ .../modules/commercial/types/supplierForm.ts | 109 +++ .../utils/__tests__/supplierEdit.spec.ts | 108 +++ .../utils/__tests__/supplierFormRules.spec.ts | 190 ++++ .../modules/commercial/utils/supplierEdit.ts | 142 +++ .../commercial/utils/supplierFormRules.ts | 215 +++++ 14 files changed, 2630 insertions(+) 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 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..dd8d5f3 --- /dev/null +++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue @@ -0,0 +1,327 @@ + + + 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..fd2ec2b --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/SupplierAddressBlock.spec.ts @@ -0,0 +1,173 @@ +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, + MalioRadioButton: true, + MalioInputNumber: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('SupplierAddressBlock — specificites M2 (radio type, bennes, triage)', () => { + it('rend les 3 options de type d\'adresse (Prospect / Départ / Rendu)', () => { + const wrapper = mountBlock() + expect(wrapper.findAll('malio-radio-button-stub')).toHaveLength(3) + }) + + 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)', () => { + const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.' }) + expect(wrapper.text()).toContain('Le type d\'adresse doit être Prospect, Départ ou Rendu.') + }) + + 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..9da0287 --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/SupplierContactBlock.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } 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..e130c01 --- /dev/null +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -0,0 +1,862 @@ + + + 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..e1a2665 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/supplierEdit.spec.ts @@ -0,0 +1,108 @@ +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('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..a765053 --- /dev/null +++ b/frontend/modules/commercial/utils/supplierFormRules.ts @@ -0,0 +1,215 @@ +/** + * 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 +export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['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 +} -- 2.39.5 From 86373f0d3cf723ed3bc5f92ef87fa5b824552a04 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:46:40 +0200 Subject: [PATCH 4/6] =?UTF-8?q?refactor(front)=20:=20volume=20pr=C3=A9visi?= =?UTF-8?q?onnel=20en=20champ=20texte=20masqu=C3=A9=20+=20bloc=20adresse?= =?UTF-8?q?=20fournisseur=20align=C3=A9=20sur=20le=20client=20(radio=20typ?= =?UTF-8?q?e=20sans=20label,=20une=20colonne,=20required=20par=20radio)=20?= =?UTF-8?q?(ERP-94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/SupplierAddressBlock.vue | 116 +++++++++--------- .../commercial/pages/suppliers/new.vue | 12 +- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/frontend/modules/commercial/components/SupplierAddressBlock.vue b/frontend/modules/commercial/components/SupplierAddressBlock.vue index dd8d5f3..30b59b5 100644 --- a/frontend/modules/commercial/components/SupplierAddressBlock.vue +++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue @@ -11,29 +11,64 @@ /> -
- - {{ t('commercial.suppliers.form.address.addressType') }} * - -
- -
+ Une seule colonne (radios empiles), sans label de groupe ; le caractere + obligatoire est porte par chaque radio (prop `required`). L'erreur 422 + (propertyPath `addressType`) s'affiche sous le groupe. --> +
+ {{ errors.addressType }}
+ + + + + + + +