{
- if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
+ if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue
index 73b5734..c50096a 100644
--- a/frontend/modules/commercial/pages/clients/new.vue
+++ b/frontend/modules/commercial/pages/clients/new.vue
@@ -76,7 +76,7 @@
@@ -140,13 +140,12 @@
+ actif par defaut). Onglet facultatif : un enregistrement a
+ vide reste possible, c'est le back qui valide. -->
@@ -178,7 +177,7 @@
@@ -216,7 +215,7 @@
@@ -347,7 +346,7 @@
@@ -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 {
const relation = (value === null || value === '')
? null
@@ -551,17 +532,18 @@ async function onRelationChange(value: string | number | null): Promise {
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise {
- if (!isMainValid.value || mainSubmitting.value) return
+ if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
- const payload: Record = {
+ // companyName omis si vide -> 422 NotBlank au lieu d'un 400 de type (ERP-119).
+ const payload: Record = 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('/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 {
- 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 {
- 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 {
}
},
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 {
- 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 {
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 {
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 {
- 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 {
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`,
diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts
index b0e5ea4..bbe4d8f 100644
--- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts
+++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts
@@ -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', () => {
diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
index 139f568..30c677a 100644
--- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
+++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
@@ -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 })
+ })
+})
diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts
index c7551f6..53e3290 100644
--- a/frontend/modules/commercial/utils/clientEdit.ts
+++ b/frontend/modules/commercial/utils/clientEdit.ts
@@ -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 {
- 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 {
- 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 {
- 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 ────────────────────────────────────────────────────
diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts
index a9ea3b5..9ab1bb8 100644
--- a/frontend/modules/commercial/utils/clientFormRules.ts
+++ b/frontend/modules/commercial/utils/clientFormRules.ts
@@ -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>(
+ 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
+}
diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php
index 99a998f..ab77987 100644
--- a/src/Module/Commercial/Domain/Entity/ClientAddress.php
+++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php
@@ -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
diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php
index 93eb41c..41a1b21 100644
--- a/tests/Module/Commercial/Api/ClientAddressTest.php
+++ b/tests/Module/Commercial/Api/ClientAddressTest.php
@@ -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).
*/
diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php
index 5b4ecca..a7d4182 100644
--- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php
+++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php
@@ -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',