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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user