feat(commercial) : amélioration et validation stricte des champs date (ERP-148) (#92)
Auto Tag Develop / tag (push) Successful in 8s

## Contexte
ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur).

## Changements
- **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`).
- **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**.
  - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté.
- **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`.
- Réorganisation des utils de formulaire sous `utils/forms/`.

## Tests
- Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`).
- Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs).
- Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué).

## Note
Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation.

Reviewed-on: #92
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #92.
This commit is contained in:
2026-06-12 08:45:38 +00:00
committed by Autin
parent 7d8a633eee
commit a340d8139a
33 changed files with 364 additions and 41 deletions
@@ -0,0 +1,250 @@
import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type ClientDetail,
} from '../clientConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('relationOf', () => {
it('detecte une relation distributeur et expose son nom', () => {
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
})
it('detecte une relation courtier et expose son nom', () => {
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
})
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/18',
id: 18,
firstName: 'Sophie',
lastName: 'Léonard',
jobTitle: 'Gérante',
phonePrimary: '0549112233',
email: 'sophie@x.fr',
})
expect(draft.id).toBe(18)
expect(draft.iri).toBe('/api/client_contacts/18')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/19',
id: 19,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
const draft = mapAddressToDraft({
'@id': '/api/client_addresses/18',
id: 18,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '5 rue des Courtiers',
billingEmail: 'factures@x.fr',
isProspect: false,
isDelivery: false,
isBilling: true,
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
})
expect(draft.siteIris).toEqual(['/api/sites/4'])
expect(draft.categoryIris).toEqual(['/api/categories/3'])
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
expect(draft.isBilling).toBe(true)
expect(draft.city).toBe('Châtellerault')
expect(draft.country).toBe('France')
})
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.isBilling).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/clients/1',
id: 1,
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1' },
paymentDelay: { '@id': '/api/payment_delays/2' },
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as ClientDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/2',
paymentTypeIri: '/api/payment_types/10',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
const acc = mapAccountingDraft({} as ClientDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/10', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/client_addresses/18',
id: 18,
city: 'Châtellerault',
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
describe('canEditClient', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET client non archive', () => {
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET client archive', () => {
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -0,0 +1,338 @@
import { describe, expect, it } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type InformationFormDraft,
type MainFormDraft,
} from '../clientEdit'
import type { ClientDetail } from '../clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return {
companyName: 'ACME',
categoryIris: ['/api/categories/1'],
relationType: null,
distributorIri: null,
brokerIri: null,
triageService: false,
...overrides,
}
}
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
return {
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
foundedAtRaw: '',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
directorName: 'PDG',
...overrides,
}
}
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
return {
siren: '123456789',
accountNumber: 'C-001',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
bankIri: '/api/banks/1',
...overrides,
}
}
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [
// relationType : champ transitoire envoye au back pour la validation croisee
// « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119).
'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService',
]
const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
'revenueAmount', 'profitAmount', 'directorName',
]
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
})
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'distributeur',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.distributor).toBe('/api/clients/9')
expect(payload.broker).toBeNull()
})
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'courtier',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.broker).toBe('/api/clients/7')
expect(payload.distributor).toBeNull()
})
it('sans relation : distributor et broker a null', () => {
const payload = buildMainPayload(mainDraft({ relationType: null }))
expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull()
})
it('transmet relationType au back pour la validation croisee (RG-1.03 bis)', () => {
expect(buildMainPayload(mainDraft({ relationType: 'distributeur' })).relationType).toBe('distributeur')
expect(buildMainPayload(mainDraft({ relationType: 'courtier' })).relationType).toBe('courtier')
expect(buildMainPayload(mainDraft({ relationType: null })).relationType).toBeNull()
})
// ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le
// champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back
// renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type.
it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false)
})
it('omet companyName quand il est une chaine vide', () => {
expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false)
})
it('conserve companyName quand il est renseigne', () => {
expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME')
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
})
it('convertit employeesCount en nombre et vide -> null', () => {
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
})
it('chaines vides normalisees en null', () => {
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull()
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
.toBe('2010-05-01')
})
})
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
})
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
})
})
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('contact : telephone secondaire ignore si non revele', () => {
const contact: ContactFormDraft = {
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
email: null, hasSecondaryPhone: false,
}
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
})
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true,
}
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
// 2e email : transmis si facturation + revele, sinon null (ERP-119).
expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr')
expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull()
expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull()
})
it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
})
// ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour
// declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation.
it('rib partiel : omet label / bic vides, conserve iban', () => {
const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' }
const payload = buildRibPayload(rib)
expect('label' in payload).toBe(false)
expect('bic' in payload).toBe(false)
expect(payload.iban).toBe('FR7612345')
})
// ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank).
it('adresse partielle : omet postalCode / city / street vides', () => {
const address: AddressFormDraft = {
id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: null, city: '', street: null, streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false)
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis / booleens restent presents.
expect(payload.isDelivery).toBe(true)
expect(payload.sites).toEqual(['/api/sites/1'])
})
})
// Bug edition : en PATCH (merge), une cle de champ requis OMISE laisse la valeur
// serveur inchangee -> faux 200 quand l'utilisateur vide le champ. En `forUpdate`,
// on envoie `''` (chaine valide, pas de 400 de type) -> NotBlank 422 inline.
describe('forUpdate (EDITION/PATCH) : champ requis vide -> `\'\'` au lieu d\'etre omis', () => {
it('buildMainPayload : companyName vide envoye en `\'\'`', () => {
const payload = buildMainPayload(mainDraft({ companyName: '' }), { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
it('buildAddressPayload : postalCode / city / street vides envoyes en `\'\'`', () => {
const address: AddressFormDraft = {
id: 7, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: '', city: null, street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false,
}
const payload = buildAddressPayload(address, false, { forUpdate: true })
expect(payload.postalCode).toBe('')
expect(payload.city).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.street).toBe('1 rue X')
})
it('buildRibPayload : label / bic vides envoyes en `\'\'`, iban conserve', () => {
const payload = buildRibPayload({ id: 4, label: '', bic: null, iban: 'FR7612345' }, { forUpdate: true })
expect(payload.label).toBe('')
expect(payload.bic).toBe('')
expect(payload.iban).toBe('FR7612345')
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = {
'@id': '/api/clients/1', id: 1,
companyName: 'ACME', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail
const draft = mapMainDraft(client)
expect(draft.companyName).toBe('ACME')
expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9')
expect(draft.brokerIri).toBeNull()
expect(draft.triageService).toBe(true)
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false)
})
})
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
const draft = mapInformationDraft({
'@id': '/api/clients/1', id: 1,
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
} as ClientDetail)
expect(draft.foundedAt).toBe('2010-05-01')
expect(draft.employeesCount).toBe('42')
expect(draft.revenueAmount).toBe('1000000')
})
it('cles omises -> null', () => {
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/clients/1', id: 1,
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
paymentType: '/api/payment_types/3',
} as ClientDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -0,0 +1,420 @@
import { describe, it, expect } from 'vitest'
import {
addressFlagsFromType,
addressTypeFromFlags,
applyProspectExclusivity,
buildClientFormTabKeys,
canSelectDeliveryOrBilling,
canSelectProspect,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressFlagsDraft,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
} from '../clientFormRules'
/** Bloc contact totalement vide (amorce par defaut). */
function blankContact(): ContactFillableDraft {
return {
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
}
}
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildClientFormTabKeys(true)).toContain('accounting')
})
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
expect(buildClientFormTabKeys(false)).not.toContain('accounting')
})
it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
const keys = buildClientFormTabKeys(true)
expect(keys).toEqual(['information', 'contact', 'address', 'transport', 'accounting'])
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', () => {
const keys = buildClientFormTabKeys(true, { includeEditOnlyTabs: true })
expect(keys).toEqual([
'information',
'contact',
'address',
'transport',
'accounting',
'statistics',
'reports',
'exchanges',
])
})
})
describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => {
it('Adresse pour un role sans Comptabilite (Bureau / Commerciale)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(false))).toBe('address')
})
it('Comptabilite pour un role avec accounting.view (Admin)', () => {
expect(lastFillableTabKey(buildClientFormTabKeys(true))).toBe('accounting')
})
it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => {
expect(lastFillableTabKey(['information', 'contact', 'address', 'transport'])).toBe('address')
})
it('undefined si aucun onglet remplissable (que des placeholders)', () => {
expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined()
})
})
describe('isContactNamed (RG-1.05)', () => {
it('vrai si le prenom est renseigne', () => {
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
})
it('vrai si le nom est renseigne', () => {
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('isBlankRow (primitive : toutes les valeurs vides)', () => {
it('vrai si toutes les valeurs sont nulles / vides / espaces', () => {
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
expect(isBlankRow([])).toBe(true)
})
it('faux des qu une valeur porte un caractere non-espace', () => {
expect(isBlankRow([null, 'x', ''])).toBe(false)
})
})
describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => {
it('vrai si label / bic / iban sont tous vides', () => {
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true)
})
it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
})
it('faux si seul le libelle est saisi', () => {
expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false)
})
})
describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => {
it('vrai si aucun champ saisissable n est rempli', () => {
expect(isContactBlank(blankContact())).toBe(true)
expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true)
})
it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => {
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
})
it('faux si seul un telephone, une fonction ou un nom est saisi', () => {
expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false)
expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
it('faux sur une liste vide', () => {
expect(hasAtLeastOneValidContact([])).toBe(false)
})
it('faux si aucun contact n a de nom ni prenom', () => {
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', () => {
const contacts: ContactDraft[] = [
{ firstName: null, lastName: null },
{ firstName: 'Bob', lastName: null },
]
expect(hasAtLeastOneValidContact(contacts)).toBe(true)
})
})
/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */
function flags(overrides: Partial<AddressFlagsDraft> = {}): AddressFlagsDraft {
return {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
...overrides,
}
}
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect(flags())).toBe(true)
expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false)
expect(canSelectProspect(flags({ isBilling: true }))).toBe(false)
})
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling(flags())).toBe(true)
expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false)
})
it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true)
expect(next).toEqual(flags({ isProspect: true }))
})
it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true)
expect(next).toEqual(flags({ isDelivery: true }))
})
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true)
expect(next).toEqual(flags({ isDelivery: true, isBilling: true }))
})
it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false)
expect(next).toEqual(flags({ isDelivery: true }))
})
})
describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true)
expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false)
})
})
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true }))
expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true }))
expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true }))
expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true }))
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
})
it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect')
expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery')
expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing')
expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing')
expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker')
expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor')
})
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags(flags())).toBeNull()
})
it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
}
})
})
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
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('hasAllRequiredAccountingFields (RG-1.30)', () => {
const complete = {
siren: '123456789',
accountNumber: '00012345678',
nTva: 'FR12345678901',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
}
it('vrai quand les six champs obligatoires sont remplis', () => {
expect(hasAllRequiredAccountingFields(complete)).toBe(true)
})
it('faux si un champ est manquant (null ou vide apres trim)', () => {
expect(hasAllRequiredAccountingFields({ ...complete, siren: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, accountNumber: ' ' })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, tvaModeIri: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, paymentTypeIri: null })).toBe(false)
})
it('faux quand tout est vide (onglet non rempli)', () => {
expect(hasAllRequiredAccountingFields({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
})).toBe(false)
})
})
describe('showsRelationAndTriageFields (affichage Relation + Triage selon categorie)', () => {
it('faux par defaut (aucune categorie selectionnee)', () => {
expect(showsRelationAndTriageFields([])).toBe(false)
})
it('faux si seules des categories Distributeur / Courtier sont selectionnees', () => {
expect(showsRelationAndTriageFields(['DISTRIBUTEUR'])).toBe(false)
expect(showsRelationAndTriageFields(['COURTIER'])).toBe(false)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'COURTIER'])).toBe(false)
})
it('vrai des qu\'une categorie ordinaire est selectionnee', () => {
expect(showsRelationAndTriageFields(['CLIENT'])).toBe(true)
expect(showsRelationAndTriageFields(['DISTRIBUTEUR', 'CLIENT'])).toBe(true)
})
})
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
isBroker: false,
isDistributor: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
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 (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
describe('omitEmptyRequired (ERP-119 : 422 NotBlank au lieu de 400 de type)', () => {
it('retire les cles requises vides (null / vide / undefined)', () => {
const payload = omitEmptyRequired(
{ companyName: null, label: '', iban: undefined, categories: ['/api/categories/1'] },
['companyName', 'label', 'iban'],
)
expect('companyName' in payload).toBe(false)
expect('label' in payload).toBe(false)
expect('iban' in payload).toBe(false)
// Les cles hors liste ne sont jamais touchees.
expect(payload.categories).toEqual(['/api/categories/1'])
})
it('conserve les cles requises renseignees', () => {
const payload = omitEmptyRequired({ companyName: 'ACME', bic: 'BNPAFRPP' }, ['companyName', 'bic'])
expect(payload).toEqual({ companyName: 'ACME', bic: 'BNPAFRPP' })
})
it('ne retire jamais une cle hors de la liste requise, meme vide', () => {
const payload = omitEmptyRequired({ streetComplement: null }, ['street'])
expect('streetComplement' in payload).toBe(true)
expect(payload.streetComplement).toBeNull()
})
it('false / 0 ne sont pas consideres vides (booleens / nombres preserves)', () => {
const payload = omitEmptyRequired({ isDelivery: false, position: 0 }, ['isDelivery', 'position'])
expect(payload).toEqual({ isDelivery: false, position: 0 })
})
})
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type SupplierDetail,
} from '../supplierConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/14', code: 'LCR' })).toBe('/api/payment_types/14')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/39',
id: 39,
firstName: 'Marie',
lastName: 'Martin',
jobTitle: 'Responsable achats',
phonePrimary: '0612345678',
email: 'marie.martin@seed.test',
})
expect(draft.id).toBe(39)
expect(draft.iri).toBe('/api/supplier_contacts/39')
expect(draft.phonePrimary).toBe('06 12 34 56 78')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/supplier_contacts/40',
id: 40,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('mappe l\'enum addressType, les champs fournisseur et extrait les iris', () => {
const draft = mapAddressToDraft({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'DEPART',
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '12 rue des Acacias',
bennes: 3,
triageProvider: true,
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
contacts: [{ '@id': '/api/supplier_contacts/39' }, '/api/supplier_contacts/41'],
})
expect(draft.addressType).toBe('DEPART')
expect(draft.siteIris).toEqual(['/api/sites/87'])
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
expect(draft.contactIris).toEqual(['/api/supplier_contacts/39', '/api/supplier_contacts/41'])
// bennes (entier) → chaine pour MalioInputNumber.
expect(draft.bennes).toBe('3')
expect(draft.triageProvider).toBe(true)
expect(draft.city).toBe('Poitiers')
expect(draft.country).toBe('France')
})
it('tolere les champs absents (defauts : France, bennes « 0 », triage faux, type null)', () => {
const draft = mapAddressToDraft({ '@id': '/api/supplier_addresses/9', id: 9 })
expect(draft.addressType).toBeNull()
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.bennes).toBe('0')
expect(draft.triageProvider).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/supplier_ribs/27', id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 27, label: 'Compte principal', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/suppliers/85',
id: 85,
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30' },
paymentDelay: { '@id': '/api/payment_delays/11' },
paymentType: { '@id': '/api/payment_types/14', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as SupplierDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: 'F0001',
nTva: 'FR00123456789',
tvaModeIri: '/api/tva_modes/30',
paymentDelayIri: '/api/payment_delays/11',
paymentTypeIri: '/api/payment_types/14',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (gating par omission, sans accounting.view)', () => {
const acc = mapAccountingDraft({} as SupplierDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }])).toEqual([
{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/87', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/supplier_contacts/1', id: 1, firstName: 'Marie', lastName: 'Martin' },
{ '@id': '/api/supplier_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/supplier_contacts/1', label: 'Marie Martin' },
{ value: '/api/supplier_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/14', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/14', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/supplier_addresses/33',
id: 33,
addressType: 'RENDU',
city: 'Poitiers',
sites: [{ '@id': '/api/sites/87', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/2279', name: 'Negociant', code: 'NEGOCIANT' }],
})
expect(view.draft.id).toBe(33)
expect(view.draft.addressType).toBe('RENDU')
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }])
})
})
describe('canEditSupplier', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditSupplier(can(['commercial.suppliers.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditSupplier(can(['commercial.suppliers.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditSupplier(can(['commercial.suppliers.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET fournisseur non archive', () => {
expect(showArchiveAction(can(['commercial.suppliers.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.suppliers.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET fournisseur archive', () => {
expect(showRestoreAction(can(['commercial.suppliers.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.suppliers.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
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('CREATION : omet companyName vide (-> 422 NotBlank, ERP-119)', () => {
const payload = buildMainPayload({ companyName: null, categoryIris: [] })
expect('companyName' in payload).toBe(false)
expect(payload.categories).toEqual([])
})
it('EDITION (forUpdate) : companyName vide envoye en `\'\'` (PATCH -> 422 NotBlank, pas un faux 200)', () => {
const payload = buildMainPayload({ companyName: '', categoryIris: [] }, { forUpdate: true })
expect('companyName' in payload).toBe(true)
expect(payload.companyName).toBe('')
})
})
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', 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 })
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
.toBe('2008-04-01')
})
})
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
it('n\'envoie le 2e telephone que si revele (hasSecondaryPhone)', () => {
const contact = { ...emptyContact(), phonePrimary: '0102030405', phoneSecondary: '0607080910' }
expect(buildContactPayload({ ...contact, hasSecondaryPhone: false }).phoneSecondary).toBeNull()
expect(buildContactPayload({ ...contact, hasSecondaryPhone: true }).phoneSecondary).toBe('0607080910')
})
})
describe('buildAddressPayload (sous-ressource supplier_address — specificites M2)', () => {
it('envoie addressType (enum), bennes (nombre) et triageProvider', () => {
const address = {
...emptyAddress(),
addressType: 'RENDU' as const,
postalCode: '86100', city: 'Châtellerault', street: '1 rue de la Paix',
siteIris: ['/api/sites/1'], categoryIris: ['/api/categories/2'],
bennes: '3', triageProvider: true,
}
expect(buildAddressPayload(address)).toMatchObject({
addressType: 'RENDU',
bennes: 3,
triageProvider: true,
sites: ['/api/sites/1'],
categories: ['/api/categories/2'],
})
})
it('bennes null quand le champ est vide', () => {
expect(buildAddressPayload({ ...emptyAddress(), bennes: '' }).bennes).toBeNull()
})
it('omet postalCode / city / street vides (-> 422 NotBlank, ERP-119)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'PROSPECT' })
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis restent presents.
expect('streetComplement' in payload).toBe(true)
expect(payload.addressType).toBe('PROSPECT')
})
it('omet addressType quand aucun radio n\'est choisi (-> 422 NotBlank au lieu d\'un 400 de type)', () => {
// emptyAddress() laisse addressType a null : la cle doit etre absente du
// payload pour que le back renvoie une 422 propertyPath addressType.
const payload = buildAddressPayload(emptyAddress())
expect('addressType' in payload).toBe(false)
})
it('EDITION (forUpdate) : un champ requis vide est envoye en `\'\'` (et NON omis) pour declencher la 422 NotBlank au PATCH', () => {
// Bug edition : omettre la cle d'un champ requis vide laisse le PATCH garder
// l'ancienne valeur (faux 200). En `forUpdate`, on envoie `''` -> NotBlank 422.
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART', postalCode: '' }, { forUpdate: true })
expect('postalCode' in payload).toBe(true)
expect(payload.postalCode).toBe('')
// Un champ requis renseigne reste tel quel.
expect(payload.addressType).toBe('DEPART')
})
it('n\'expose jamais d\'email de facturation (difference M1)', () => {
const payload = buildAddressPayload({ ...emptyAddress(), addressType: 'DEPART' })
expect('billingEmail' in payload).toBe(false)
})
})
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')
})
})
describe('mapMainDraft — pre-remplissage bloc principal (companyName + categories, pas de relation M2)', () => {
it('extrait companyName et les IRI de categories', () => {
const draft = mapMainDraft({
'@id': '/api/suppliers/85', id: 85,
companyName: 'DOD862875',
categories: [{ '@id': '/api/categories/2279', code: 'NEGOCIANT' }],
} as SupplierDetail)
expect(draft.companyName).toBe('DOD862875')
expect(draft.categoryIris).toEqual(['/api/categories/2279'])
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/suppliers/2', id: 2 } as SupplierDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
})
})
describe('mapInformationDraft — pre-remplissage onglet Information (+ volumeForecast M2)', () => {
it('tronque foundedAt, stringifie employeesCount et volumeForecast', () => {
const draft = mapInformationDraft({
'@id': '/api/suppliers/85', id: 85,
foundedAt: '2008-04-01T00:00:00+02:00', employeesCount: 42, volumeForecast: 8000,
} as SupplierDetail)
expect(draft.foundedAt).toBe('2008-04-01')
expect(draft.employeesCount).toBe('42')
expect(draft.volumeForecast).toBe('8000')
})
it('cles omises -> null (volumeForecast inclus)', () => {
const draft = mapInformationDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.volumeForecast).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/suppliers/85', id: 85,
siren: '123456789', accountNumber: 'F0001', nTva: 'FR00123456789',
tvaMode: { '@id': '/api/tva_modes/30', label: 'France (ventes)' },
paymentType: '/api/payment_types/14',
} as SupplierDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/30')
expect(draft.paymentTypeIri).toBe('/api/payment_types/14')
expect(draft.bankIri).toBeNull()
})
it('cles comptables absentes (gating par omission) -> scalaires/IRI null', () => {
const draft = mapAccountingFormDraft({ '@id': '/api/suppliers/1', id: 1 } as SupplierDetail)
expect(draft.siren).toBeNull()
expect(draft.tvaModeIri).toBeNull()
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -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 })
})
})