feat(front) : page Ajouter un fournisseur (/suppliers/new) + workflow par onglets (ERP-94)

This commit is contained in:
2026-06-09 22:37:30 +02:00
parent 639cf8482f
commit 01a3bd6419
14 changed files with 2630 additions and 0 deletions
@@ -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')
})
})
@@ -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 })
})
})