From 01a3bd6419ba5e5283c3d1543adc5f0aa69c304a Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 22:37:30 +0200 Subject: [PATCH] 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 +}