8490de99da
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1). ## Contenu ### Validation front (clients) - Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ. - Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type. - Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05). - Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ». ### Nouveaux types d'adresse - Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads). ### Saisies manuelles - Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien. - Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier. ### 2e email de facturation - Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`). ### Fin d'ajout d'un client - Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom. ## Vérifications - Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test). - Front : Vitest vert (272), ESLint OK. > Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation. Reviewed-on: #80 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
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',
|
|
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()
|
|
})
|
|
})
|
|
|
|
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'])
|
|
})
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|