feat(commercial) : revue de la validation front clients + RG type d'adresse (ERP-119)

- Boutons « Valider » toujours actifs (retrait du gating de validite) : le back renvoie les 422 mappees en rouge par champ.
- Champs requis a colonne non-nullable : la cle est omise du payload si vide (companyName, RIB, adresse) -> 422 NotBlank au lieu d'un 400 de type a la deserialisation.
- Onglet Contact : au moins un contact requis -> l'amorce vide est soumise pour declencher la 422 RG-1.05 quand aucun contact n'est nomme.
- Adresse : affichage inline des erreurs type / sites / categories, et nouvelle RG back « au moins un type d'adresse obligatoire » (Callback sur isProspect).
This commit is contained in:
2026-06-09 13:13:50 +02:00
parent b3ab23ee8f
commit 982f501b94
11 changed files with 267 additions and 111 deletions
@@ -14,12 +14,15 @@
remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
<MalioSelect
:model-value="addressType"
:options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')"
:readonly="readonly"
:required="true"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
/>
@@ -31,6 +34,7 @@
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
@@ -65,6 +69,7 @@
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
@@ -134,6 +134,32 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
// le champ correspondant (bindings :error de ClientAddressBlock).
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
const field = wrapper.findAll('malio-select-stub').find(
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
)
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
})
it('affiche les erreurs serveur sur sites et categories', () => {
const wrapper = mountWithErrors({
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
})
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
@@ -82,7 +82,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -178,7 +178,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +216,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +347,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -419,8 +419,6 @@ import {
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -673,17 +671,6 @@ const {
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation
@@ -697,7 +684,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
if (businessReadonly.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
@@ -750,9 +737,6 @@ const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -774,7 +758,7 @@ function askRemoveContact(index: number): void {
* collection contacts (endpoints client_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
@@ -783,6 +767,11 @@ async function submitContacts(): Promise<void> {
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
// obligatoire » inline (la RG-1.14 n'a pas d'equivalent back au POST).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -805,10 +794,10 @@ async function submitContacts(): Promise<void> {
}
},
error => showError(error),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de toast succes.
if (hasError) return
@@ -823,10 +812,6 @@ async function submitContacts(): Promise<void> {
}
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -859,7 +844,7 @@ function onAddressDegraded(): void {
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
@@ -927,13 +912,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -965,7 +943,7 @@ function askRemoveRib(index: number): void {
* 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
@@ -76,7 +76,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!isMainValid || mainSubmitting"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
@@ -140,13 +140,12 @@
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet
actif par defaut) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
actif par defaut). Onglet facultatif : un enregistrement a
vide reste possible, c'est le back qui valide. -->
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
:disabled="tabSubmitting || clientId === null"
@click="submitInformation"
/>
</div>
@@ -178,7 +177,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateContacts || tabSubmitting"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
@@ -216,7 +215,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAddresses || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
@@ -347,7 +346,7 @@
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="!canValidateAccounting || tabSubmitting"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
@@ -389,11 +388,9 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
@@ -402,6 +399,9 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
import {
@@ -517,25 +517,6 @@ watch(showRelationAndTriage, (visible) => {
}
})
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
// devient requis si l'un des deux est choisi (spec fonctionnelle).
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
// distributeur/courtier » est choisi, le nom correspondant devient requis.
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '')
? null
@@ -551,17 +532,18 @@ async function onRelationChange(value: string | number | null): Promise<void> {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const payload: Record<string, unknown> = {
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
const payload: Record<string, unknown> = omitEmptyRequired({
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
const created = await api.post<ClientResponse>('/clients', payload, {
headers: { Accept: 'application/ld+json' },
toast: false,
@@ -661,12 +643,9 @@ const information = reactive({
directorName: null as string | null,
})
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
@@ -701,9 +680,6 @@ const canAddContact = computed(() => {
return last !== undefined && isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
@@ -717,9 +693,14 @@ function askRemoveContact(index: number): void {
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on ne les skippe pas -> le bloc vide est POSTe et
// le back renvoie la 422 RG-1.05 « prénom ou nom obligatoire » inline (la
// RG-1.14 n'a pas d'equivalent back au POST, on la materialise via RG-1.05).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
@@ -749,10 +730,10 @@ async function submitContacts(): Promise<void> {
}
},
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
// serait perdue en silence avec un faux toast de succes).
contact => contact.id === null && isContactBlank(contact),
// On ne saute une amorce neuve (id null) totalement vide QUE si un autre
// bloc sera soumis : sinon on la soumet pour declencher la 422 RG-1.05
// (un onglet Contact vide ne doit pas passer en faux succes).
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
if (hasError) return
@@ -789,12 +770,6 @@ const countryOptions: RefOption[] = [
{ value: 'Espagne', label: 'Espagne' },
]
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
@@ -824,7 +799,7 @@ function onAddressDegraded(): void {
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
@@ -832,7 +807,8 @@ async function submitAddresses(): Promise<void> {
addresses.value,
addressErrors,
async (address) => {
const body = {
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
const body = omitEmptyRequired({
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
@@ -845,7 +821,7 @@ async function submitAddresses(): Promise<void> {
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
@@ -909,16 +885,6 @@ function onPaymentTypeChange(value: string | number | null): void {
}
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true
})
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
@@ -947,7 +913,7 @@ function askRemoveRib(index: number): void {
* il n'existe pas d'endpoint /accounting, cf. recon back).
*/
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
@@ -959,7 +925,9 @@ async function submitAccounting(): Promise<void> {
ribs.value,
ribErrors,
async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400
// de type sur un RIB partiel (ex. IBAN seul). ERP-119.
const body = omitEmptyRequired({ label: rib.label, bic: rib.bic, iban: rib.iban }, RIB_REQUIRED_NON_NULLABLE_KEYS)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
@@ -99,6 +99,21 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull()
expect(payload.broker).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', () => {
@@ -155,6 +170,33 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
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, country: 'France',
postalCode: null, city: '', street: null, streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null,
}
const payload = buildAddressPayload(address, false)
expect('postalCode' in payload).toBe(false)
expect('city' in payload).toBe(false)
expect('street' in payload).toBe(false)
// Les champs non requis / booleens restent presents.
expect(payload.isDelivery).toBe(true)
expect(payload.sites).toEqual(['/api/sites/1'])
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
@@ -18,6 +18,7 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressValidityDraft,
type ContactDraft,
@@ -369,3 +370,33 @@ describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
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 })
})
})
@@ -21,6 +21,12 @@ import {
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
@@ -139,13 +145,14 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
* que la FK correspondant au type choisi, l'autre est forcee a null.
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return {
// companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
return omitEmptyRequired({
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
}, MAIN_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
@@ -198,7 +205,8 @@ export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
): Record<string, unknown> {
return {
// postalCode / city / street omis si vides -> 422 NotBlank (ERP-119).
return omitEmptyRequired({
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
@@ -211,16 +219,18 @@ export function buildAddressPayload(
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
}
}, ADDRESS_REQUIRED_NON_NULLABLE_KEYS)
}
/** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
return {
// label / bic / iban omis si vides -> 422 NotBlank au lieu d'un 400 de type
// sur un RIB partiel (ex. IBAN seul). ERP-119.
return omitEmptyRequired({
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
}, RIB_REQUIRED_NON_NULLABLE_KEYS)
}
// ── Gating par permission ────────────────────────────────────────────────────
@@ -358,3 +358,38 @@ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDra
&& 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
}
@@ -223,6 +223,23 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
}
}
/**
* Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
* Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
* La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
* un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
*/
#[Assert\Callback]
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
{
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling) {
$context->buildViolation('Le type d\'adresse est obligatoire.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
@@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => 'parasite@test.fr',
'postalCode' => '86100',
@@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => '',
'postalCode' => '86100',
@@ -201,6 +203,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -229,6 +232,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -253,6 +257,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -277,6 +282,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -301,6 +307,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -311,6 +318,39 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422);
}
/**
* RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison /
* Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec
* une violation portee sur `isProspect` (mappee sous le select « Type
* d'adresse » cote front via ClientAddressBlock).
*/
public function testAddressRequiresAtLeastOneType(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
}
/**
* Retourne l'IRI du premier site seede (fixtures Sites).
*/
@@ -237,6 +237,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -258,6 +259,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -287,6 +289,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
@@ -313,6 +316,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',