bc7c8f6f83
Auto Tag Develop / tag (push) Successful in 8s
## ERP-65 — Page Modification client (1.12)
Écran d'édition client à plat `/clients/[id]/edit`, pré-rempli depuis `GET /clients/{id}` (via `useClient`), édition **indépendante par onglet** avec PATCH **scopé au groupe de sérialisation dédié** (mode strict ERP-74).
### Périmètre
- **Bloc principal conservé** (décision produit) : éditable, PATCH `/clients/{id}` scopé `client:write:main`.
- Onglets **Information** / **Comptabilité** : PATCH `/clients/{id}` scopés à leur groupe ; **Contacts / Adresses / RIBs** via leurs sous-ressources (POST nouveau / PATCH existant / DELETE retiré).
- **Gating readonly par permission** : `manage` → bloc principal + Info/Contact/Adresse éditables ; Comptabilité visible ssi `accounting.view`, éditable ssi `accounting.manage`. Garde de route si ni `manage` ni `accounting.manage`.
- **Pas de miroir RG-1.04 côté front** (cohérent avec la création — le 422 serveur remonte au toast).
- **Chargement résilient des référentiels** (`loadCommon` → `Promise.allSettled`) + options en **union avec l'embed**, pour que les selects comptables de Compta se chargent malgré les 403 sur `/categories`+`/sites`, et que les valeurs courantes s'affichent toujours.
### Tests / vérifications
- Vitest : 22 nouveaux tests (`clientEdit.spec.ts` — scoping strict par groupe + gating par rôle + mappers) ; suite **180/180 OK**, aucune régression.
- ESLint propre.
- Golden path navigateur (Admin + Compta) : pré-remplissage, PATCH Information strictement scopé (corps = 7 champs information), gating readonly Compta, référentiels comptables chargés malgré 403 categories/sites, PATCH comptable Compta OK (200).
### À signaler (hors périmètre)
Les rôles métier (Bureau/Commerciale/Compta) n'ont pas `catalog.categories.view`/`sites.view` → 403 sur `/categories`/`/sites`. La page se dégrade proprement (valeurs courantes via embed) mais **ajouter une nouvelle catégorie/site** est impossible pour ces rôles (même limite que la création). Correctif = ticket RBAC backend (3 miroirs).
Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
buildAccountingPayload,
|
|
buildAddressPayload,
|
|
buildContactPayload,
|
|
buildInformationPayload,
|
|
buildMainPayload,
|
|
buildRibPayload,
|
|
mapAccountingFormDraft,
|
|
mapInformationDraft,
|
|
mapMainDraft,
|
|
resolveTabEditability,
|
|
type AccountingFormDraft,
|
|
type InformationFormDraft,
|
|
type MainFormDraft,
|
|
} from '../clientEdit'
|
|
import type { ClientDetail } from '../clientConsultation'
|
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
|
|
|
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
|
|
|
|
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
|
|
return {
|
|
companyName: 'ACME',
|
|
firstName: 'Jean',
|
|
lastName: 'Dupont',
|
|
email: 'jean@acme.fr',
|
|
phonePrimary: '05 49 11 22 33',
|
|
phoneSecondary: null,
|
|
hasSecondaryPhone: false,
|
|
categoryIris: ['/api/categories/1'],
|
|
relationType: null,
|
|
distributorIri: null,
|
|
brokerIri: null,
|
|
triageService: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
|
|
return {
|
|
description: 'desc',
|
|
competitors: 'concurrents',
|
|
foundedAt: '2010-05-01',
|
|
employeesCount: '42',
|
|
revenueAmount: '1000000',
|
|
profitAmount: '50000',
|
|
directorName: 'PDG',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
|
|
return {
|
|
siren: '123456789',
|
|
accountNumber: 'C-001',
|
|
nTva: 'FR123',
|
|
tvaModeIri: '/api/tva_modes/1',
|
|
paymentDelayIri: '/api/payment_delays/1',
|
|
paymentTypeIri: '/api/payment_types/1',
|
|
bankIri: '/api/banks/1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
|
|
const MAIN_KEYS = [
|
|
'companyName', 'firstName', 'lastName', 'email', 'phonePrimary',
|
|
'phoneSecondary', 'categories', '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('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
|
|
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
|
|
expect(payload.phoneSecondary).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
|
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
|
|
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
|
|
})
|
|
|
|
it('convertit employeesCount en nombre et vide -> null', () => {
|
|
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
|
|
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
|
|
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
|
|
})
|
|
|
|
it('chaines vides normalisees en null', () => {
|
|
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
|
|
expect(payload.description).toBeNull()
|
|
expect(payload.directorName).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
|
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
|
|
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
|
|
})
|
|
|
|
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
|
|
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
|
|
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
|
|
it('contact : telephone secondaire ignore si non revele', () => {
|
|
const contact: ContactFormDraft = {
|
|
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
|
|
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
|
|
email: null, hasSecondaryPhone: false,
|
|
}
|
|
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
|
|
})
|
|
|
|
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
|
const address: AddressFormDraft = {
|
|
id: 3, isProspect: false, isDelivery: false, isBilling: true, 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',
|
|
}
|
|
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
|
expect(buildAddressPayload(address, false).billingEmail).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...' })
|
|
})
|
|
})
|
|
|
|
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
|
it('formate les telephones, resout la relation et extrait les IRI', () => {
|
|
const client = {
|
|
'@id': '/api/clients/1', id: 1,
|
|
companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr',
|
|
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
|
|
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
|
|
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
|
|
} as ClientDetail
|
|
|
|
const draft = mapMainDraft(client)
|
|
expect(draft.phonePrimary).toBe('05 49 11 22 33')
|
|
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
|
|
expect(draft.hasSecondaryPhone).toBe(true)
|
|
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.hasSecondaryPhone).toBe(false)
|
|
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 })
|
|
})
|
|
})
|