feat(commercial) : amélioration et validation stricte des champs date (ERP-148) (#92)
Auto Tag Develop / tag (push) Successful in 8s
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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
|
||||
*
|
||||
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
|
||||
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
|
||||
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
|
||||
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
||||
* unitairement (cf. clientConsultation.spec.ts).
|
||||
*
|
||||
* Rappels de contrat back (verifies sur l'API reelle) :
|
||||
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
|
||||
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
|
||||
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
||||
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
|
||||
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import type {
|
||||
AddressFormDraft,
|
||||
ContactFormDraft,
|
||||
RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Site embarque dans une adresse (groupe site:read). */
|
||||
export interface SiteRead extends HydraRef {
|
||||
name?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee (groupe category:read). */
|
||||
export interface CategoryRead extends HydraRef {
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Contact embarque (groupe client_contact:read). */
|
||||
export interface ContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Adresse embarquee (groupe client_address:read). */
|
||||
export interface AddressRead extends HydraRef {
|
||||
id: number
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
billingEmail?: string | null
|
||||
billingEmailSecondary?: string | null
|
||||
isProspect?: boolean
|
||||
isDelivery?: boolean
|
||||
isBilling?: boolean
|
||||
isBroker?: boolean
|
||||
isDistributor?: boolean
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
|
||||
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
|
||||
export interface RibRead extends HydraRef {
|
||||
id: number
|
||||
label?: string | null
|
||||
bic?: string | null
|
||||
iban?: string | null
|
||||
}
|
||||
|
||||
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
|
||||
export interface RelatedClientRead extends HydraRef {
|
||||
companyName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
|
||||
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
||||
* peuvent omettre n'importe quelle cle.
|
||||
*/
|
||||
export interface ClientDetail extends HydraRef {
|
||||
id: number
|
||||
companyName?: string | null
|
||||
triageService?: boolean
|
||||
isArchived?: boolean
|
||||
categories?: CategoryRead[]
|
||||
distributor?: RelatedClientRead | string | null
|
||||
broker?: RelatedClientRead | string | null
|
||||
contacts?: ContactRead[]
|
||||
addresses?: AddressRead[]
|
||||
ribs?: RibRead[]
|
||||
// Onglet Information
|
||||
description?: string | null
|
||||
competitors?: string | null
|
||||
foundedAt?: string | null
|
||||
employeesCount?: number | null
|
||||
revenueAmount?: string | null
|
||||
profitAmount?: string | null
|
||||
directorName?: string | null
|
||||
// Onglet Comptabilite (present ssi accounting.view)
|
||||
siren?: string | null
|
||||
accountNumber?: string | null
|
||||
nTva?: string | null
|
||||
tvaMode?: Relation
|
||||
paymentDelay?: Relation
|
||||
paymentType?: Relation
|
||||
bank?: Relation
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
|
||||
export interface AccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
nTva: string | null
|
||||
tvaModeIri: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
|
||||
export interface ClientRelation {
|
||||
type: 'distributeur' | 'courtier' | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
||||
export interface CategorySelectOption extends SelectOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
||||
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
||||
*/
|
||||
export interface AddressView {
|
||||
draft: AddressFormDraft
|
||||
siteOptions: SelectOption[]
|
||||
categoryOptions: CategorySelectOption[]
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
|
||||
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
|
||||
* un IRI nu ou absente.
|
||||
*/
|
||||
export function relationOf(client: ClientDetail): ClientRelation {
|
||||
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
|
||||
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
|
||||
|
||||
if (client.distributor) {
|
||||
return { type: 'distributeur', name: nameOf(client.distributor) }
|
||||
}
|
||||
if (client.broker) {
|
||||
return { type: 'courtier', name: nameOf(client.broker) }
|
||||
}
|
||||
return { type: null, name: null }
|
||||
}
|
||||
|
||||
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
|
||||
const phoneSecondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
iri: contact['@id'] ?? null,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
isProspect: address.isProspect ?? false,
|
||||
isDelivery: address.isDelivery ?? false,
|
||||
isBilling: address.isBilling ?? false,
|
||||
isBroker: address.isBroker ?? false,
|
||||
isDistributor: address.isDistributor ?? false,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
billingEmail: address.billingEmail ?? null,
|
||||
billingEmailSecondary: address.billingEmailSecondary ?? null,
|
||||
hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un RIB embarque vers un brouillon. */
|
||||
export function mapRibToDraft(rib: RibRead): RibFormDraft {
|
||||
return {
|
||||
id: rib.id,
|
||||
label: rib.label ?? null,
|
||||
bic: rib.bic ?? null,
|
||||
iban: rib.iban ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
|
||||
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
|
||||
return {
|
||||
siren: client.siren ?? null,
|
||||
accountNumber: client.accountNumber ?? null,
|
||||
nTva: client.nTva ?? null,
|
||||
tvaModeIri: iriOf(client.tvaMode),
|
||||
paymentDelayIri: iriOf(client.paymentDelay),
|
||||
paymentTypeIri: iriOf(client.paymentType),
|
||||
bankIri: iriOf(client.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
||||
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
||||
* roles metier non-admin), qui laisserait les libelles vides.
|
||||
*/
|
||||
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
||||
return (categories ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: c.name ?? c.code ?? c['@id'],
|
||||
code: c.code ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
||||
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
||||
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||
}
|
||||
|
||||
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
|
||||
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
||||
return (contacts ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
||||
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
||||
*/
|
||||
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return []
|
||||
}
|
||||
const label = (relation.label as string | undefined)
|
||||
?? (relation.name as string | undefined)
|
||||
?? relation['@id']
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||
* hors-LCR en consultation).
|
||||
*/
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||
export function mapAddressView(address: AddressRead): AddressView {
|
||||
return {
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
||||
* par onglet est gere sur l'ecran d'edition (1.12).
|
||||
*/
|
||||
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
|
||||
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET client encore actif. */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.clients.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET client deja archive. */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.clients.archive') && isArchived
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
|
||||
*
|
||||
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
|
||||
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
|
||||
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
|
||||
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
|
||||
* comptabilite reutilises depuis clientConsultation).
|
||||
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : 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.
|
||||
*
|
||||
* NOTE : l'onglet Information est facultatif pour tous les roles (RG-1.04
|
||||
* « Information obligatoire pour la Commerciale » retiree cote back).
|
||||
*/
|
||||
|
||||
import {
|
||||
iriOf,
|
||||
relationOf,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||
blankEmptyRequired,
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/**
|
||||
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
|
||||
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
|
||||
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
|
||||
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
|
||||
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
|
||||
*/
|
||||
export interface MainFormDraft {
|
||||
companyName: string | null
|
||||
/** IRI des categories rattachees (M2M). */
|
||||
categoryIris: string[]
|
||||
relationType: 'distributeur' | 'courtier' | null
|
||||
distributorIri: string | null
|
||||
brokerIri: string | null
|
||||
triageService: boolean
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Information (groupe client: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
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
profitAmount: string | null
|
||||
directorName: string | null
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Comptabilite (groupe client: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
|
||||
}
|
||||
|
||||
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
|
||||
export interface ClientEditAbilities {
|
||||
/** `commercial.clients.manage` : bloc principal + onglets metier. */
|
||||
canManage: boolean
|
||||
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
|
||||
canAccountingView: boolean
|
||||
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
|
||||
canAccountingManage: boolean
|
||||
}
|
||||
|
||||
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
|
||||
export interface TabEditability {
|
||||
/** Bloc principal + onglets Information / Contact / Adresse editables. */
|
||||
businessEditable: boolean
|
||||
/** Onglet Comptabilite present (affiche). */
|
||||
accountingVisible: boolean
|
||||
/** Onglet Comptabilite editable. */
|
||||
accountingEditable: boolean
|
||||
}
|
||||
|
||||
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Mappe le detail client vers le brouillon du bloc principal. La relation
|
||||
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
|
||||
* de l'embed.
|
||||
*/
|
||||
export function mapMainDraft(client: ClientDetail): MainFormDraft {
|
||||
const relation = relationOf(client)
|
||||
|
||||
return {
|
||||
companyName: client.companyName ?? null,
|
||||
categoryIris: (client.categories ?? []).map(c => c['@id']),
|
||||
relationType: relation.type,
|
||||
distributorIri: iriOf(client.distributor),
|
||||
brokerIri: iriOf(client.broker),
|
||||
triageService: client.triageService === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe le detail client vers le brouillon de l'onglet Information. */
|
||||
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
|
||||
return {
|
||||
description: client.description ?? null,
|
||||
competitors: client.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||
revenueAmount: client.revenueAmount ?? null,
|
||||
profitAmount: client.profitAmount ?? null,
|
||||
directorName: client.directorName ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
||||
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
|
||||
return {
|
||||
siren: client.siren ?? null,
|
||||
accountNumber: client.accountNumber ?? null,
|
||||
nTva: client.nTva ?? null,
|
||||
tvaModeIri: iriOf(client.tvaMode),
|
||||
paymentDelayIri: iriOf(client.paymentDelay),
|
||||
paymentTypeIri: iriOf(client.paymentType),
|
||||
bankIri: iriOf(client.bank),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options de construction d'un payload d'ecriture.
|
||||
* - `forUpdate: false` (defaut, CREATION/POST) : champs requis vides OMIS -> 422
|
||||
* NotBlank (le back ne reçoit pas la cle, la propriete garde son defaut).
|
||||
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : champs requis vides
|
||||
* envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur serveur
|
||||
* inchangee, faux 200 — cf. blankEmptyRequired).
|
||||
*/
|
||||
export interface BuildPayloadOptions {
|
||||
forUpdate?: boolean
|
||||
}
|
||||
|
||||
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
|
||||
function finalizeRequired<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
requiredKeys: readonly string[],
|
||||
options: BuildPayloadOptions,
|
||||
): T {
|
||||
return options.forUpdate
|
||||
? blankEmptyRequired(payload, requiredKeys)
|
||||
: omitEmptyRequired(payload, requiredKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
||||
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
||||
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||
*/
|
||||
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
|
||||
// relationType : champ transitoire (non persiste cote back) qui porte
|
||||
// l'intention UI « ce client depend d'un distributeur / courtier ». Il sert
|
||||
// a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie,
|
||||
// la FK correspondante devient obligatoire -> 422 sur distributor / broker.
|
||||
// Sans equivalent derivable cote back (FK nullable), c'est la seule facon de
|
||||
// rester sur « on soumet, le back tranche » plutot qu'une garde front-only.
|
||||
return finalizeRequired({
|
||||
companyName: main.companyName,
|
||||
categories: main.categoryIris,
|
||||
relationType: main.relationType,
|
||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||
triageService: main.triageService,
|
||||
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
directorName: information.directorName || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
|
||||
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
|
||||
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
|
||||
*/
|
||||
export function buildAccountingPayload(
|
||||
accounting: AccountingFormDraft,
|
||||
isBankRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
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 client_contact). */
|
||||
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
|
||||
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 client_address). */
|
||||
export function buildAddressPayload(
|
||||
address: AddressFormDraft,
|
||||
isBillingEmailRequired: boolean,
|
||||
options: BuildPayloadOptions = {},
|
||||
): Record<string, unknown> {
|
||||
// postalCode / city / street : omis a la creation, `''` en edition -> 422 NotBlank (ERP-119).
|
||||
return finalizeRequired({
|
||||
isProspect: address.isProspect,
|
||||
isDelivery: address.isDelivery,
|
||||
isBilling: address.isBilling,
|
||||
isBroker: address.isBroker,
|
||||
isDistributor: address.isDistributor,
|
||||
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,
|
||||
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||
billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null,
|
||||
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||
export function buildRibPayload(rib: RibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||
// label / bic / iban : omis a la creation, `''` en edition -> 422 NotBlank au lieu
|
||||
// d'un 400 de type (ou d'un faux 200 PATCH qui garderait l'ancienne valeur). ERP-119.
|
||||
return finalizeRequired({
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
// ── Gating par permission ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
||||
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
|
||||
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
|
||||
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
|
||||
*
|
||||
* Produit le comportement attendu :
|
||||
* - Admin : tout editable.
|
||||
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
|
||||
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
|
||||
*/
|
||||
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
|
||||
return {
|
||||
businessEditable: abilities.canManage,
|
||||
accountingVisible: abilities.canAccountingView,
|
||||
accountingEditable: abilities.canAccountingManage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
|
||||
*
|
||||
* Centralisees ici (hors composant) pour rester testables unitairement et
|
||||
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
|
||||
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
|
||||
* brouillons « plats » et retournent des booleens / nouveaux objets.
|
||||
*
|
||||
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
|
||||
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
|
||||
*
|
||||
* NOTE : l'onglet Information est facultatif pour tous les roles. L'ancienne
|
||||
* RG-1.04 (« Information obligatoire pour la Commerciale ») a ete retiree cote
|
||||
* back — rien a miroiter ici.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Onglets « coquille » (non encore implementes) : frame vide, passage
|
||||
* automatique a l'onglet suivant (decision Tristan 28/05).
|
||||
*/
|
||||
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
|
||||
|
||||
/**
|
||||
* Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la
|
||||
* creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
|
||||
* d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`.
|
||||
*/
|
||||
export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
|
||||
|
||||
/**
|
||||
* Construit l'ordre des onglets du formulaire client.
|
||||
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
|
||||
* (Bureau / Commerciale ne le voient pas).
|
||||
* - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par
|
||||
* defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en
|
||||
* modification.
|
||||
* Ordre aligne sur la spec M1 § Ecran « Ajouter un client ».
|
||||
*/
|
||||
export function buildClientFormTabKeys(
|
||||
canAccountingView: boolean,
|
||||
options: { includeEditOnlyTabs?: boolean } = {},
|
||||
): string[] {
|
||||
const keys = ['information', 'contact', 'address', 'transport']
|
||||
if (canAccountingView) {
|
||||
keys.push('accounting')
|
||||
}
|
||||
if (options.includeEditOnlyTabs) {
|
||||
keys.push(...CLIENT_FORM_EDIT_ONLY_TABS)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un
|
||||
* placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer
|
||||
* les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que
|
||||
* si accounting.view). Sa validation marque la fin de l'ajout (redirection liste).
|
||||
*/
|
||||
export function lastFillableTabKey(tabKeys: string[]): string | undefined {
|
||||
return [...tabKeys].reverse().find(
|
||||
key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes de categorie « intermediaire » : un client dont la categorie est
|
||||
* Distributeur ou Courtier n'a ni relation amont (il EST le distributeur /
|
||||
* courtier) ni prestation de triage. Sert a conditionner l'affichage des champs
|
||||
* « Relation » et « Prestation de triage » du formulaire principal.
|
||||
*/
|
||||
export const DISTRIBUTOR_BROKER_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'] as const
|
||||
|
||||
/**
|
||||
* Vrai des qu'au moins une categorie « ordinaire » (autre que Distributeur /
|
||||
* Courtier) est selectionnee. Les champs « Relation » (depend du distributeur /
|
||||
* courtier) et « Prestation de triage » du formulaire principal sont masques par
|
||||
* defaut et reveles uniquement dans ce cas.
|
||||
*/
|
||||
export function showsRelationAndTriageFields(selectedCategoryCodes: string[]): boolean {
|
||||
return selectedCategoryCodes.some(
|
||||
code => !(DISTRIBUTOR_BROKER_CATEGORY_CODES as readonly string[]).includes(code),
|
||||
)
|
||||
}
|
||||
|
||||
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
|
||||
export interface ContactDraft {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
}
|
||||
|
||||
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
|
||||
export interface AddressFlagsDraft {
|
||||
isProspect: boolean
|
||||
isDelivery: boolean
|
||||
isBilling: boolean
|
||||
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
|
||||
isBroker: boolean
|
||||
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
|
||||
isDistributor: boolean
|
||||
}
|
||||
|
||||
/** 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-1.05 : 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-1.14 : l'onglet Contact 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 (null /
|
||||
* undefined / espaces uniquement). 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-1.05 (« prenom ou nom obligatoire ») 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 (label / bic / iban) inline plutot que d'etre saute silencieusement.
|
||||
*/
|
||||
export function isRibBlank(rib: RibFillableDraft): boolean {
|
||||
return isBlankRow([rib.label, rib.bic, rib.iban])
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
|
||||
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
|
||||
* dernier bloc doit etre complet avant d'en ajouter un autre) 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
||||
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
||||
* Facturation ne sont coches.
|
||||
*/
|
||||
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
|
||||
return !flags.isDelivery && !flags.isBilling
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
|
||||
* Prospect n'est pas coche.
|
||||
*/
|
||||
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
|
||||
return !flags.isProspect
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
|
||||
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
|
||||
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
|
||||
* nouvel objet (pas de mutation de l'entree).
|
||||
*/
|
||||
export function applyProspectExclusivity(
|
||||
flags: AddressFlagsDraft,
|
||||
field: keyof AddressFlagsDraft,
|
||||
value: boolean,
|
||||
): AddressFlagsDraft {
|
||||
const next: AddressFlagsDraft = { ...flags, [field]: value }
|
||||
|
||||
if (value && field === 'isProspect') {
|
||||
next.isDelivery = false
|
||||
next.isBilling = false
|
||||
}
|
||||
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
|
||||
next.isProspect = false
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
|
||||
* est une adresse de facturation.
|
||||
*/
|
||||
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
||||
return flags.isBilling
|
||||
}
|
||||
|
||||
/**
|
||||
* Type d'adresse expose a l'utilisateur (Select unique remplacant les trois
|
||||
* cases a cocher). Sucre purement front : le back continue de recevoir les
|
||||
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
|
||||
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
|
||||
*/
|
||||
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
|
||||
|
||||
/**
|
||||
* Mappe le type d'adresse choisi vers les cinq drapeaux back.
|
||||
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
|
||||
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
|
||||
*/
|
||||
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
||||
const none: AddressFlagsDraft = {
|
||||
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
|
||||
}
|
||||
switch (type) {
|
||||
case 'prospect':
|
||||
return { ...none, isProspect: true }
|
||||
case 'delivery':
|
||||
return { ...none, isDelivery: true }
|
||||
case 'billing':
|
||||
return { ...none, isBilling: true }
|
||||
case 'delivery_billing':
|
||||
return { ...none, isDelivery: true, isBilling: true }
|
||||
case 'broker':
|
||||
return { ...none, isBroker: true }
|
||||
case 'distributor':
|
||||
return { ...none, isDistributor: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit le type d'adresse a partir des drapeaux (consultation / edition
|
||||
* d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau
|
||||
* n'est positionne — le Select reste alors a saisir (et bloque la validation).
|
||||
*/
|
||||
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
|
||||
if (flags.isProspect) return 'prospect'
|
||||
if (flags.isBroker) return 'broker'
|
||||
if (flags.isDistributor) return 'distributor'
|
||||
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
|
||||
if (flags.isDelivery) return 'delivery'
|
||||
if (flags.isBilling) return 'billing'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
|
||||
* d'usage (pour le type + l'email de facturation conditionnel), sites et
|
||||
* categories rattaches, email de facturation.
|
||||
*/
|
||||
export interface AddressValidityDraft extends AddressFlagsDraft {
|
||||
categoryIris: string[]
|
||||
siteIris: string[]
|
||||
billingEmail: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
|
||||
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
|
||||
* facturation (RG-1.11). 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).
|
||||
*/
|
||||
export function isAddressValid(address: AddressValidityDraft): boolean {
|
||||
return addressTypeFromFlags(address) !== null
|
||||
&& address.siteIris.length >= 1
|
||||
&& address.categoryIris.length >= 1
|
||||
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
|
||||
}
|
||||
|
||||
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
||||
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
||||
|
||||
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
|
||||
const PAYMENT_TYPE_LCR = 'LCR'
|
||||
|
||||
/**
|
||||
* RG-1.12 : 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-1.13 : 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 saisissables de l'onglet Information (tous facultatifs). */
|
||||
export interface InformationFieldsDraft {
|
||||
description: string | null
|
||||
competitors: string | null
|
||||
foundedAt: string | null
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
profitAmount: string | null
|
||||
directorName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
|
||||
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
|
||||
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
|
||||
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
|
||||
* une action legitime : ce gate n'y est pas applique.)
|
||||
*/
|
||||
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
|
||||
return !isBlankRow([
|
||||
information.description,
|
||||
information.competitors,
|
||||
information.foundedAt,
|
||||
information.employeesCount,
|
||||
information.revenueAmount,
|
||||
information.profitAmount,
|
||||
information.directorName,
|
||||
])
|
||||
}
|
||||
|
||||
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
|
||||
export interface AccountingRequiredDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
nTva: string | null
|
||||
tvaModeIri: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires
|
||||
* pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de
|
||||
* reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 /
|
||||
* RG-1.13) et sont evalues a part. Miroir front du
|
||||
* ClientAccountingCompletenessValidator : meme gate que les onglets Contact /
|
||||
* Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet).
|
||||
*/
|
||||
export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean {
|
||||
const filled = (v: string | null): boolean => v !== null && v.trim() !== ''
|
||||
|
||||
return filled(accounting.siren)
|
||||
&& filled(accounting.accountNumber)
|
||||
&& filled(accounting.nTva)
|
||||
&& filled(accounting.tvaModeIri)
|
||||
&& filled(accounting.paymentDelayIri)
|
||||
&& filled(accounting.paymentTypeIri)
|
||||
}
|
||||
|
||||
// ── Champs requis adosses a une colonne NON-nullable (ERP-119) ───────────────
|
||||
// Ces champs requis (NotBlank back) sont portes par une colonne Doctrine NON
|
||||
// nullable. Si le front envoie `null` (champ vide, desormais possible : le bouton
|
||||
// « Valider » n'est plus desactive), API Platform rejette la valeur en 400 de TYPE
|
||||
// a la deserialisation (« The type of the X attribute must be string, NULL given »)
|
||||
// AVANT le Validator -> pas de violation, donc pas d'erreur rouge cote champ.
|
||||
// La parade : OMETTRE la cle du payload quand elle est vide. Sans la cle, la
|
||||
// propriete garde son defaut null cote entite et #[Assert\NotBlank] se declenche
|
||||
// normalement -> 422 avec propertyPath, mappee en rouge sous le champ.
|
||||
// (Les champs requis a colonne NULLABLE — contacts, scalaires compta — acceptent
|
||||
// deja `null` et renvoient une 422 : inutile de les omettre.)
|
||||
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.
|
||||
* A n'appliquer QU'aux cles ci-dessus (champs requis a colonne non-nullable).
|
||||
*/
|
||||
export function omitEmptyRequired<T extends Record<string, unknown>>(
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
|
||||
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
|
||||
*
|
||||
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une
|
||||
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
|
||||
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
|
||||
* `''` (chaine valide), on evite le 400 de type (« must be string, NULL given ») et
|
||||
* le Validator `NotBlank(trim)` rejette la valeur -> 422 avec propertyPath, mappee
|
||||
* inline sous le champ. Mute et retourne le payload.
|
||||
*/
|
||||
export function blankEmptyRequired<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
requiredKeys: readonly string[],
|
||||
): T {
|
||||
for (const key of requiredKeys) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
(payload as Record<string, unknown>)[key] = ''
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Helpers purs de l'ecran « Consultation fournisseur » (M2 Commercial, lecture
|
||||
* seule). Miroir de `clientConsultation.ts` (M1), adapte aux differences M2.
|
||||
*
|
||||
* Mappent le payload `GET /api/suppliers/{id}` (relations embarquees, cf. groupe
|
||||
* `supplier:item:read` + `supplier:read:accounting`) vers les brouillons « plats »
|
||||
* partages avec les blocs reutilisables `SupplierContactBlock` / `SupplierAddressBlock`
|
||||
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
||||
* unitairement (cf. supplierConsultation.spec.ts).
|
||||
*
|
||||
* Rappels de contrat back (verifies sur le JSON reel fige — ERP-92, spec-back § 4.0.bis) :
|
||||
* - les relations ManyToOne (tvaMode/paymentDelay/paymentType/bank) sont
|
||||
* serialisees en OBJETS embarques (`{id, code, label}`), pas en IRI nu ;
|
||||
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
||||
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS (cle omise, pas `null`)
|
||||
* sans permission accounting.view (gate serveur via SupplierReadGroupContextBuilder).
|
||||
*
|
||||
* Differences M2 vs M1 :
|
||||
* - Adresse via enum `addressType` (PROSPECT/DEPART/RENDU, RG-2.09) — pas de
|
||||
* drapeaux isProspect/isDelivery/isBilling.
|
||||
* - Adresse : champs specifiques fournisseur `bennes` (nombre) et `triageProvider`.
|
||||
* Pas d'email de facturation.
|
||||
* - Information : champ specifique fournisseur `volumeForecast`.
|
||||
* - Pas de relation Distributeur/Courtier ni de triage sur le bloc principal.
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import {
|
||||
emptyAddress,
|
||||
type SupplierAddressFormDraft,
|
||||
type SupplierAddressType,
|
||||
type SupplierContactFormDraft,
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Site embarque dans une adresse (groupe site:read). */
|
||||
export interface SiteRead extends HydraRef {
|
||||
name?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee (groupe category:read). */
|
||||
export interface CategoryRead extends HydraRef {
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Contact embarque (groupe supplier_contact:read). */
|
||||
export interface ContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Adresse embarquee (groupe supplier_address:read). */
|
||||
export interface AddressRead extends HydraRef {
|
||||
id: number
|
||||
addressType?: SupplierAddressType | null
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
bennes?: number | null
|
||||
triageProvider?: boolean
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
|
||||
/** RIB embarque (groupe supplier:read:accounting, present ssi accounting.view). */
|
||||
export interface RibRead extends HydraRef {
|
||||
id: number
|
||||
label?: string | null
|
||||
bic?: string | null
|
||||
iban?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail d'un fournisseur tel que renvoye par `GET /api/suppliers/{id}`. Tous les
|
||||
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
||||
* peuvent omettre n'importe quelle cle.
|
||||
*/
|
||||
export interface SupplierDetail extends HydraRef {
|
||||
id: number
|
||||
companyName?: string | null
|
||||
isArchived?: boolean
|
||||
categories?: CategoryRead[]
|
||||
contacts?: ContactRead[]
|
||||
addresses?: AddressRead[]
|
||||
ribs?: RibRead[]
|
||||
// Onglet Information
|
||||
description?: string | null
|
||||
competitors?: string | null
|
||||
foundedAt?: string | null
|
||||
employeesCount?: number | null
|
||||
revenueAmount?: string | null
|
||||
profitAmount?: string | null
|
||||
directorName?: string | null
|
||||
/** Volume previsionnel (entier, specifique fournisseur). */
|
||||
volumeForecast?: number | null
|
||||
// Onglet Comptabilite (present ssi accounting.view)
|
||||
siren?: string | null
|
||||
accountNumber?: string | null
|
||||
nTva?: string | null
|
||||
tvaMode?: Relation
|
||||
paymentDelay?: Relation
|
||||
paymentType?: Relation
|
||||
bank?: Relation
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire). */
|
||||
export interface AccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
nTva: string | null
|
||||
tvaModeIri: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
||||
export interface CategorySelectOption extends SelectOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
||||
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
||||
*/
|
||||
export interface AddressView {
|
||||
draft: SupplierAddressFormDraft
|
||||
siteOptions: SelectOption[]
|
||||
categoryOptions: CategorySelectOption[]
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: ContactRead): SupplierContactFormDraft {
|
||||
const phoneSecondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
iri: contact['@id'] ?? null,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections).
|
||||
* `bennes` (entier) est converti en chaine pour MalioInputNumber (defaut « 0 »).
|
||||
*/
|
||||
export function mapAddressToDraft(address: AddressRead): SupplierAddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
addressType: address.addressType ?? null,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
bennes: address.bennes != null ? String(address.bennes) : '0',
|
||||
triageProvider: address.triageProvider ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un RIB embarque vers un brouillon. */
|
||||
export function mapRibToDraft(rib: RibRead): SupplierRibFormDraft {
|
||||
return {
|
||||
id: rib.id,
|
||||
label: rib.label ?? null,
|
||||
bic: rib.bic ?? null,
|
||||
iban: rib.iban ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du fournisseur (scalaires + IRI des referentiels). */
|
||||
export function mapAccountingDraft(supplier: SupplierDetail): AccountingDraft {
|
||||
return {
|
||||
siren: supplier.siren ?? null,
|
||||
accountNumber: supplier.accountNumber ?? null,
|
||||
nTva: supplier.nTva ?? null,
|
||||
tvaModeIri: iriOf(supplier.tvaMode),
|
||||
paymentDelayIri: iriOf(supplier.paymentDelay),
|
||||
paymentTypeIri: iriOf(supplier.paymentType),
|
||||
bankIri: iriOf(supplier.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
||||
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
||||
* roles metier non-admin), qui laisserait les libelles vides.
|
||||
*/
|
||||
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
||||
return (categories ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: c.name ?? c.code ?? c['@id'],
|
||||
code: c.code ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
||||
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
||||
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||
}
|
||||
|
||||
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */
|
||||
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
||||
return (contacts ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
||||
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
||||
*/
|
||||
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return []
|
||||
}
|
||||
const label = (relation.label as string | undefined)
|
||||
?? (relation.name as string | undefined)
|
||||
?? relation['@id']
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
|
||||
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
|
||||
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
|
||||
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
|
||||
* hors-LCR en consultation).
|
||||
*/
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||
export function mapAddressView(address: AddressRead): AddressView {
|
||||
return {
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
||||
* par onglet est gere sur l'ecran d'edition (96).
|
||||
*/
|
||||
export function canEditSupplier(canAny: (codes: string[]) => boolean): boolean {
|
||||
return canAny(['commercial.suppliers.manage', 'commercial.suppliers.accounting.manage'])
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET fournisseur encore actif. */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.suppliers.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET fournisseur deja archive. */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.suppliers.archive') && isArchived
|
||||
}
|
||||
|
||||
/** Brouillon d'adresse vierge (reexport pour la page : 1 bloc vide si aucune adresse). */
|
||||
export { emptyAddress }
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Helpers purs des ecrans « Ajouter » / « Modifier » un fournisseur (M2
|
||||
* Commercial) — miroir de `clientEdit.ts` (M1). Deux responsabilites, toutes deux
|
||||
* testables unitairement (cf. supplierEdit.spec.ts) :
|
||||
* 1. Pre-remplissage : mapper le payload `GET /api/suppliers/{id}` (embed +
|
||||
* scalaires) vers les brouillons « plats » edites par la page de modification.
|
||||
* 2. Scoping STRICT des payloads PATCH (mode strict RG-2.16 / ERP-74) : 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,
|
||||
blankEmptyRequired,
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
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
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un fournisseur. */
|
||||
export interface SupplierEditAbilities {
|
||||
/** `commercial.suppliers.manage` : bloc principal + onglets metier. */
|
||||
canManage: boolean
|
||||
/** `commercial.suppliers.accounting.view` : visibilite de l'onglet Comptabilite. */
|
||||
canAccountingView: boolean
|
||||
/** `commercial.suppliers.accounting.manage` : edition de l'onglet Comptabilite. */
|
||||
canAccountingManage: boolean
|
||||
}
|
||||
|
||||
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
|
||||
export interface TabEditability {
|
||||
/** Bloc principal + onglets Information / Contacts / Adresses editables. */
|
||||
businessEditable: boolean
|
||||
/** Onglet Comptabilite present (affiche). */
|
||||
accountingVisible: boolean
|
||||
/** Onglet Comptabilite editable. */
|
||||
accountingEditable: boolean
|
||||
}
|
||||
|
||||
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
||||
|
||||
/** Mappe le detail fournisseur vers le brouillon du bloc principal. */
|
||||
export function mapMainDraft(supplier: SupplierDetail): MainFormDraft {
|
||||
return {
|
||||
companyName: supplier.companyName ?? null,
|
||||
categoryIris: (supplier.categories ?? []).map(c => c['@id']),
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe le detail fournisseur vers le brouillon de l'onglet Information. */
|
||||
export function mapInformationDraft(supplier: SupplierDetail): InformationFormDraft {
|
||||
return {
|
||||
description: supplier.description ?? null,
|
||||
competitors: supplier.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||
revenueAmount: supplier.revenueAmount ?? null,
|
||||
profitAmount: supplier.profitAmount ?? null,
|
||||
directorName: supplier.directorName ?? null,
|
||||
// Volume previsionnel (entier, specifique fournisseur) en chaine pour la saisie.
|
||||
volumeForecast: supplier.volumeForecast != null ? String(supplier.volumeForecast) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
||||
export function mapAccountingFormDraft(supplier: SupplierDetail): AccountingFormDraft {
|
||||
return {
|
||||
siren: supplier.siren ?? null,
|
||||
accountNumber: supplier.accountNumber ?? null,
|
||||
nTva: supplier.nTva ?? null,
|
||||
tvaModeIri: iriOf(supplier.tvaMode),
|
||||
paymentDelayIri: iriOf(supplier.paymentDelay),
|
||||
paymentTypeIri: iriOf(supplier.paymentType),
|
||||
bankIri: iriOf(supplier.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
||||
* miroir UI du re-gating champ-par-champ du SupplierProcessor) :
|
||||
* - bloc principal + Information/Contacts/Adresses : editables ssi `manage` ;
|
||||
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
|
||||
*
|
||||
* Produit le comportement attendu :
|
||||
* - Admin : tout editable.
|
||||
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
|
||||
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
|
||||
*/
|
||||
export function resolveTabEditability(abilities: SupplierEditAbilities): TabEditability {
|
||||
return {
|
||||
businessEditable: abilities.canManage,
|
||||
accountingVisible: abilities.canAccountingView,
|
||||
accountingEditable: abilities.canAccountingManage,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoping strict des payloads PATCH/POST ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options de construction d'un payload d'ecriture.
|
||||
* - `forUpdate: false` (defaut, CREATION/POST) : les champs requis vides sont OMIS
|
||||
* -> 422 NotBlank a l'insert (le back ne reçoit pas la cle).
|
||||
* - `forUpdate: true` (EDITION/PATCH d'une ligne existante) : les champs requis
|
||||
* vides sont envoyes en `''` -> 422 NotBlank (sinon une cle omise laisse la valeur
|
||||
* serveur inchangee, faux 200 — cf. blankEmptyRequired).
|
||||
*/
|
||||
export interface BuildPayloadOptions {
|
||||
forUpdate?: boolean
|
||||
}
|
||||
|
||||
/** Selectionne le finaliseur des champs requis selon création (omit) vs édition (blank). */
|
||||
function finalizeRequired<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
requiredKeys: readonly string[],
|
||||
options: BuildPayloadOptions,
|
||||
): T {
|
||||
return options.forUpdate
|
||||
? blankEmptyRequired(payload, requiredKeys)
|
||||
: omitEmptyRequired(payload, requiredKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du bloc principal — groupe supplier:write:main UNIQUEMENT.
|
||||
* companyName vide -> 422 NotBlank (omis a la creation, `''` en edition — ERP-119).
|
||||
*/
|
||||
export function buildMainPayload(main: MainFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||
return finalizeRequired({
|
||||
companyName: main.companyName,
|
||||
categories: main.categoryIris,
|
||||
}, MAIN_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
|
||||
/** Payload de l'onglet Information — groupe supplier:write:information UNIQUEMENT. */
|
||||
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||
return finalizeRequired({
|
||||
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, options)
|
||||
}
|
||||
|
||||
/** Payload d'un RIB (sous-ressource supplier_rib). */
|
||||
export function buildRibPayload(rib: SupplierRibFormDraft, options: BuildPayloadOptions = {}): Record<string, unknown> {
|
||||
return finalizeRequired({
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}, RIB_REQUIRED_NON_NULLABLE_KEYS, options)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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
|
||||
// addressType : colonne non-nullable + NotBlank cote back. Envoyer `null` (radio
|
||||
// non choisi) provoque un 400 de TYPE a la deserialisation AVANT le Validator
|
||||
// (« must be string, NULL given ») -> pas de violation, pas d'erreur inline. On
|
||||
// omet donc la cle quand elle est vide pour obtenir une 422 NotBlank propertyPath.
|
||||
export const ADDRESS_REQUIRED_NON_NULLABLE_KEYS = ['addressType', '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<T extends Record<string, unknown>>(
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante PATCH (edition d'une ligne EXISTANTE) : remplace les cles requises
|
||||
* laissees vides par une chaine vide `''` au lieu de les OMETTRE.
|
||||
*
|
||||
* Pourquoi pas `omitEmptyRequired` en edition : un PATCH a semantique merge — une
|
||||
* cle absente laisse la valeur serveur INCHANGEE. Vider un champ requis puis valider
|
||||
* renverrait alors un 200 trompeur (l'ancienne valeur est conservee). En envoyant
|
||||
* `''`, la propriete `?string` est bien deserialisee (pas de 400 de type, contrairement
|
||||
* a `null` sur une colonne non-nullable), puis le Validator `NotBlank(trim)` la rejette
|
||||
* -> 422 avec propertyPath, mappee inline sous le champ. Mute et retourne le payload.
|
||||
*/
|
||||
export function blankEmptyRequired<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
requiredKeys: readonly string[],
|
||||
): T {
|
||||
for (const key of requiredKeys) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
(payload as Record<string, unknown>)[key] = ''
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user