From 8490de99da47a63e5b42fc82fcc9a595d331e13d Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 19:47:40 +0000 Subject: [PATCH] =?UTF-8?q?ERP-119=20:=20revue=20validation=20front=20clie?= =?UTF-8?q?nts=20+=20=C3=A9volutions=20=C3=A9cran=20client=20(types=20d'ad?= =?UTF-8?q?resse,=202e=20email,=20saisies=20manuelles,=20redirection)=20(#?= =?UTF-8?q?80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1). ## Contenu ### Validation front (clients) - Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ. - Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type. - Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05). - Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ». ### Nouveaux types d'adresse - Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads). ### Saisies manuelles - Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien. - Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier. ### 2e email de facturation - Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`). ### Fin d'ajout d'un client - Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom. ## Vérifications - Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test). - Front : Vitest vert (272), ESLint OK. > Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/80 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 6 + .../components/ClientAddressBlock.vue | 44 ++++- .../__tests__/ClientAddressBlock.spec.ts | 35 ++++ .../commercial/pages/clients/[id]/edit.vue | 57 ++---- .../modules/commercial/pages/clients/new.vue | 148 ++++++--------- .../modules/commercial/types/clientForm.ts | 12 ++ .../utils/__tests__/clientEdit.spec.ts | 60 +++++- .../utils/__tests__/clientFormRules.spec.ts | 135 +++++++++----- .../commercial/utils/clientConsultation.ts | 7 + .../modules/commercial/utils/clientEdit.ts | 32 +++- .../commercial/utils/clientFormRules.ts | 73 +++++++- frontend/package-lock.json | 8 +- frontend/package.json | 2 +- migrations/Version20260609120000.php | 81 +++++++++ migrations/Version20260609140000.php | 51 ++++++ .../Commercial/Domain/Entity/Client.php | 51 ++++++ .../Domain/Entity/ClientAddress.php | 113 ++++++++++++ .../Processor/ClientAddressProcessor.php | 1 + .../Database/ColumnCommentsCatalog.php | 29 +-- .../Api/AbstractCommercialApiTestCase.php | 21 +++ .../Api/AbstractSupplierApiTestCase.php | 20 -- .../Commercial/Api/ClientAddressTest.php | 172 ++++++++++++++++++ .../Api/ClientFormulaireMainTest.php | 73 ++++++++ .../Api/ClientSerializationContractTest.php | 6 + .../Api/ClientSubResourceApiTest.php | 19 +- 25 files changed, 1011 insertions(+), 245 deletions(-) create mode 100644 migrations/Version20260609120000.php create mode 100644 migrations/Version20260609140000.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5560180..bac08d0 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -88,6 +88,7 @@ "toast": { "createSuccess": "Client créé avec succès", "updateSuccess": "Client mis à jour avec succès", + "addComplete": "Client ajouté", "archiveSuccess": "Client archivé avec succès", "restoreSuccess": "Client restauré avec succès", "error": "Une erreur est survenue. Réessayez.", @@ -173,15 +174,20 @@ "addressTypeDelivery": "Livraison", "addressTypeBilling": "Facturation", "addressTypeDeliveryBilling": "Adresse + Facturation", + "addressTypeBroker": "Adresse Courtier", + "addressTypeDistributor": "Adresse Distributeur", "categories": "Catégorie", "country": "Pays", "postalCode": "Code postal", "city": "Ville", "street": "Adresse", + "streetNotFound": "Adresse introuvable ? Saisissez-la directement.", "streetComplement": "Adresse complémentaire", "sites": "Sites", "contacts": "Contact(s) rattaché(s)", "billingEmail": "Email de facturation", + "billingEmailSecondary": "Email de facturation secondaire", + "addBillingEmail": "Ajouter un email", "remove": "Supprimer l'adresse", "add": "Nouvelle adresse", "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index a188159..3533a7d 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -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). --> + @@ -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))" /> @@ -43,9 +47,10 @@ @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" /> - + @@ -347,7 +348,7 @@ @@ -419,8 +420,6 @@ import { } from '~/modules/commercial/utils/clientEdit' import { buildClientFormTabKeys, - hasAllRequiredAccountingFields, - hasAtLeastOneValidContact, isAddressValid, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -673,17 +672,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 { const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier') main.relationType = relation @@ -697,7 +685,7 @@ async function onRelationChange(value: string | number | null): Promise { /** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */ async function submitMain(): Promise { - if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return + if (businessReadonly.value || mainSubmitting.value) return mainSubmitting.value = true mainErrors.clearErrors() try { @@ -750,9 +738,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 +759,7 @@ function askRemoveContact(index: number): void { * collection contacts (endpoints client_contact dedies). */ async function submitContacts(): Promise { - if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return + if (businessReadonly.value || tabSubmitting.value) return tabSubmitting.value = true contactErrors.value = [] try { @@ -783,6 +768,11 @@ async function submitContacts(): Promise { } 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 +795,10 @@ async function submitContacts(): Promise { } }, 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 +813,6 @@ async function submitContacts(): Promise { } // ── 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 +845,7 @@ function onAddressDegraded(): void { /** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ async function submitAddresses(): Promise { - if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return + if (businessReadonly.value || tabSubmitting.value) return tabSubmitting.value = true addressErrors.value = [] try { @@ -927,13 +913,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 +944,7 @@ function askRemoveRib(index: number): void { * 403 sur tout le payload). */ async function submitAccounting(): Promise { - 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..56d7297 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -76,7 +76,7 @@ @@ -109,6 +109,7 @@ v-model="information.foundedAt" :label="t('commercial.clients.form.information.foundedAt')" :readonly="isValidated('information')" + :editable="true" :error="informationErrors.errors.foundedAt" /> + actif par defaut). Onglet facultatif : un enregistrement a + vide reste possible, c'est le back qui valide. --> @@ -178,7 +178,7 @@ @@ -216,7 +216,7 @@ @@ -347,7 +347,7 @@ @@ -391,9 +391,6 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF import { buildClientFormTabKeys, CLIENT_FORM_PLACEHOLDER_TABS, - hasAllRequiredAccountingFields, - hasAtLeastOneInformationField, - hasAtLeastOneValidContact, isAddressValid, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -402,8 +399,14 @@ import { isRibBlank, isRibComplete, isRibRequiredForPaymentType, + lastFillableTabKey, showsRelationAndTriageFields, } from '~/modules/commercial/utils/clientFormRules' +import { + buildAddressPayload, + buildMainPayload, + buildRibPayload, +} from '~/modules/commercial/utils/clientEdit' import { emptyAddress, emptyContact, @@ -517,25 +520,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,18 +535,13 @@ 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: main.companyName, - categories: main.categoryIris, - distributor: main.relationType === 'distributeur' ? main.distributorIri : null, - broker: main.relationType === 'courtier' ? main.brokerIri : null, - triageService: main.triageService, - } - const created = await api.post('/clients', payload, { + // Payload partage avec l'edition (buildMainPayload) : meme logique + // d'omission des requis vides et meme envoi de relationType (ERP-119). + const created = await api.post('/clients', buildMainPayload(main), { headers: { Accept: 'application/ld+json' }, toast: false, }) @@ -606,6 +585,12 @@ const validated = reactive>({}) const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value)) +// Dernier onglet REMPLISSABLE par le role (cf. lastFillableTabKey) : deja role-aware +// via tabKeys (accounting present ssi accounting.view, et a la creation « present » = +// « editable » : aucun role createur n'a la Compta en lecture seule). Sa validation +// cloture l'ajout -> redirection vers la liste. +const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value)) + // Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement. const TAB_ICONS: Record = { information: 'mdi:account-outline', @@ -633,12 +618,23 @@ function tabIndex(key: string): number { return tabKeys.value.indexOf(key) } -/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */ -function completeTab(key: string): void { +/** + * Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est + * termine : toast final + redirection vers la liste, et on retourne true pour que + * l'appelant n'affiche pas son toast « mis a jour ». Sinon, deverrouille et avance + * a l'onglet suivant, et retourne false. + */ +function completeTab(key: string): boolean { validated[key] = true + if (key === lastFillableTab.value) { + toast.success({ title: t('commercial.clients.toast.addComplete') }) + router.push('/clients') + return true + } const next = tabKeys.value[tabIndex(key) + 1] unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1) if (next) activeTab.value = next + return false } // Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges). @@ -661,12 +657,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 { @@ -679,7 +672,7 @@ async function submitInformation(): Promise { profitAmount: information.profitAmount || null, directorName: information.directorName || null, }, { toast: false }) - completeTab('information') + if (completeTab('information')) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } catch (error) { @@ -701,9 +694,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 +707,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,14 +744,14 @@ 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 - completeTab('contact') + if (completeTab('contact')) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } finally { @@ -789,12 +784,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 +813,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,20 +821,8 @@ async function submitAddresses(): Promise { addresses.value, addressErrors, async (address) => { - const body = { - isProspect: address.isProspect, - isDelivery: address.isDelivery, - isBilling: address.isBilling, - country: address.country, - postalCode: address.postalCode || null, - city: address.city || null, - street: address.street || null, - streetComplement: address.streetComplement || null, - categories: address.categoryIris, - sites: address.siteIris, - contacts: address.contactIris, - billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, - } + // Payload partage avec l'edition (buildAddressPayload, ERP-119). + const body = buildAddressPayload(address, isBillingEmailRequired(address)) if (address.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId.value}/addresses`, @@ -861,7 +838,7 @@ async function submitAddresses(): Promise { error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), ) if (hasError) return - completeTab('address') + if (completeTab('address')) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } finally { @@ -909,16 +886,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 +914,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 +926,8 @@ async function submitAccounting(): Promise { ribs.value, ribErrors, async (rib) => { - const body = { label: rib.label, bic: rib.bic, iban: rib.iban } + // Payload partage avec l'edition (buildRibPayload, ERP-119). + const body = buildRibPayload(rib) if (rib.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId.value}/ribs`, @@ -997,7 +965,7 @@ async function submitAccounting(): Promise { return } - completeTab('accounting') + if (completeTab('accounting')) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } finally { diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts index c24474d..9313bf3 100644 --- a/frontend/modules/commercial/types/clientForm.ts +++ b/frontend/modules/commercial/types/clientForm.ts @@ -30,6 +30,10 @@ export interface AddressFormDraft { isProspect: boolean isDelivery: boolean isBilling: boolean + /** Adresse Courtier — type autonome exclusif. */ + isBroker: boolean + /** Adresse Distributeur — type autonome exclusif. */ + isDistributor: boolean country: string postalCode: string | null city: string | null @@ -43,6 +47,10 @@ export interface AddressFormDraft { contactIris: string[] /** Email de facturation (obligatoire si isBilling — RG-1.11). */ billingEmail: string | null + /** 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). */ + billingEmailSecondary: string | null + /** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */ + hasSecondaryBillingEmail: boolean } /** Un RIB du client (onglet Comptabilite). */ @@ -75,6 +83,8 @@ export function emptyAddress(): AddressFormDraft { isProspect: false, isDelivery: false, isBilling: false, + isBroker: false, + isDistributor: false, country: 'France', postalCode: null, city: null, @@ -84,6 +94,8 @@ export function emptyAddress(): AddressFormDraft { siteIris: [], contactIris: [], billingEmail: null, + billingEmailSecondary: null, + hasSecondaryBillingEmail: false, } } diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index b0e5ea4..60bf4ac 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -61,7 +61,9 @@ function accountingDraft(overrides: Partial = {}): Accounti // Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe // main : les coordonnees vivent desormais sur la sous-ressource ClientContact. const MAIN_KEYS = [ - 'companyName', 'categories', 'distributor', 'broker', 'triageService', + // relationType : champ transitoire envoye au back pour la validation croisee + // « relation choisie => FK obligatoire » (RG-1.03 bis, ERP-119). + 'companyName', 'categories', 'relationType', 'distributor', 'broker', 'triageService', ] const INFORMATION_KEYS = [ 'description', 'competitors', 'foundedAt', 'employeesCount', @@ -99,6 +101,27 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => { expect(payload.distributor).toBeNull() expect(payload.broker).toBeNull() }) + + it('transmet relationType au back pour la validation croisee (RG-1.03 bis)', () => { + expect(buildMainPayload(mainDraft({ relationType: 'distributeur' })).relationType).toBe('distributeur') + expect(buildMainPayload(mainDraft({ relationType: 'courtier' })).relationType).toBe('courtier') + expect(buildMainPayload(mainDraft({ relationType: null })).relationType).toBeNull() + }) + + // ERP-119 : companyName est requis ET adosse a une colonne NON-nullable. Si le + // champ est vide, on OMET la cle (au lieu d'envoyer null) pour que le back + // renvoie une 422 NotBlank (propertyPath companyName) et non un 400 de type. + it('omet companyName quand il est vide (null) -> 422 NotBlank cote back', () => { + expect('companyName' in buildMainPayload(mainDraft({ companyName: null }))).toBe(false) + }) + + it('omet companyName quand il est une chaine vide', () => { + expect('companyName' in buildMainPayload(mainDraft({ companyName: '' }))).toBe(false) + }) + + it('conserve companyName quand il est renseigne', () => { + expect(buildMainPayload(mainDraft({ companyName: 'ACME' })).companyName).toBe('ACME') + }) }) describe('buildInformationPayload — scoping strict groupe client:write:information', () => { @@ -142,19 +165,50 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { const address: AddressFormDraft = { - id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France', + id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France', postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], - billingEmail: 'facturation@acme.fr', + billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true, } expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') expect(buildAddressPayload(address, false).billingEmail).toBeNull() + // 2e email : transmis si facturation + revele, sinon null (ERP-119). + expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr') + expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull() + expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull() }) it('rib : label / bic / iban transmis tels quels', () => { const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) }) + + // ERP-119 : un RIB partiel (IBAN seul) doit omettre label/bic vides pour + // declencher la 422 NotBlank par champ, pas un 400 de type a la deserialisation. + it('rib partiel : omet label / bic vides, conserve iban', () => { + const rib: RibFormDraft = { id: null, label: null, bic: null, iban: 'FR7612345' } + const payload = buildRibPayload(rib) + expect('label' in payload).toBe(false) + expect('bic' in payload).toBe(false) + expect(payload.iban).toBe('FR7612345') + }) + + // ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank). + it('adresse partielle : omet postalCode / city / street vides', () => { + const address: AddressFormDraft = { + id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France', + postalCode: null, city: '', street: null, streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false, + } + const payload = buildAddressPayload(address, false) + expect('postalCode' in payload).toBe(false) + expect('city' in payload).toBe(false) + expect('street' in payload).toBe(false) + // Les champs non requis / booleens restent presents. + expect(payload.isDelivery).toBe(true) + expect(payload.sites).toEqual(['/api/sites/1']) + }) }) 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..f5e2b10 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -18,7 +18,10 @@ import { isRibBlank, isRibComplete, isRibRequiredForPaymentType, + lastFillableTabKey, + omitEmptyRequired, showsRelationAndTriageFields, + type AddressFlagsDraft, type AddressValidityDraft, type ContactDraft, type ContactFillableDraft, @@ -68,6 +71,24 @@ describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only }) }) +describe('lastFillableTabKey (redirection fin d\'ajout, role-aware)', () => { + it('Adresse pour un role sans Comptabilite (Bureau / Commerciale)', () => { + expect(lastFillableTabKey(buildClientFormTabKeys(false))).toBe('address') + }) + + it('Comptabilite pour un role avec accounting.view (Admin)', () => { + expect(lastFillableTabKey(buildClientFormTabKeys(true))).toBe('accounting') + }) + + it('ignore les onglets placeholder (Transport en dernier ne compte pas)', () => { + expect(lastFillableTabKey(['information', 'contact', 'address', 'transport'])).toBe('address') + }) + + it('undefined si aucun onglet remplissable (que des placeholders)', () => { + expect(lastFillableTabKey(['transport', 'statistics'])).toBeUndefined() + }) +}) + describe('isContactNamed (RG-1.05)', () => { it('vrai si le prenom est renseigne', () => { expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) @@ -148,83 +169,79 @@ describe('hasAtLeastOneValidContact (RG-1.14)', () => { }) }) +/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */ +function flags(overrides: Partial = {}): AddressFlagsDraft { + return { + isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false, + ...overrides, + } +} + describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => { it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => { - expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) - expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) - expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false) + expect(canSelectProspect(flags())).toBe(true) + expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false) + expect(canSelectProspect(flags({ isBilling: true }))).toBe(false) }) it('Livraison / Facturation selectionnables tant que pas Prospect', () => { - expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) - expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false) + expect(canSelectDeliveryOrBilling(flags())).toBe(true) + expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false) }) it('cocher Prospect efface Livraison et Facturation', () => { - const next = applyProspectExclusivity( - { isProspect: false, isDelivery: true, isBilling: true }, - 'isProspect', - true, - ) - expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false }) + const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true) + expect(next).toEqual(flags({ isProspect: true })) }) it('cocher Livraison efface Prospect', () => { - const next = applyProspectExclusivity( - { isProspect: true, isDelivery: false, isBilling: false }, - 'isDelivery', - true, - ) - expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true) + expect(next).toEqual(flags({ isDelivery: true })) }) it('cocher Facturation efface Prospect mais conserve Livraison', () => { - const next = applyProspectExclusivity( - { isProspect: true, isDelivery: true, isBilling: false }, - 'isBilling', - true, - ) - expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true }) + const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true) + expect(next).toEqual(flags({ isDelivery: true, isBilling: true })) }) it('decocher un drapeau ne reactive rien d autre', () => { - const next = applyProspectExclusivity( - { isProspect: false, isDelivery: true, isBilling: true }, - 'isBilling', - false, - ) - expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false) + expect(next).toEqual(flags({ isDelivery: true })) }) }) describe('isBillingEmailRequired (RG-1.11)', () => { it('obligatoire uniquement si Facturation est coche', () => { - expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true) - expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) + expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true) + expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false) }) }) describe('type d\'adresse (Select front) <-> drapeaux back', () => { it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => { - expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false }) - expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) - expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true }) - expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true }) + expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true })) + expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true })) + expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true })) + expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true })) + expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true })) + expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true })) }) - it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => { - expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect') - expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery') - expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing') - expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing') + it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => { + expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect') + expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery') + expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing') + expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing') + expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker') + expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor') }) it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => { - expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull() + expect(addressTypeFromFlags(flags())).toBeNull() }) - it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => { - for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) { + it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => { + for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) { expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type) } }) @@ -324,6 +341,8 @@ describe('isAddressValid (gating « + Adresse » + validation onglet)', () => { isProspect: false, isDelivery: true, isBilling: false, + isBroker: false, + isDistributor: false, categoryIris: ['/api/client_categories/1'], siteIris: ['/api/sites/1'], billingEmail: null, @@ -369,3 +388,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/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts index 1b3761f..ae0366a 100644 --- a/frontend/modules/commercial/utils/clientConsultation.ts +++ b/frontend/modules/commercial/utils/clientConsultation.ts @@ -63,9 +63,12 @@ export interface AddressRead extends HydraRef { street?: string | null streetComplement?: string | null billingEmail?: string | null + billingEmailSecondary?: string | null isProspect?: boolean isDelivery?: boolean isBilling?: boolean + isBroker?: boolean + isDistributor?: boolean sites?: SiteRead[] categories?: CategoryRead[] // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. @@ -209,6 +212,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft { isProspect: address.isProspect ?? false, isDelivery: address.isDelivery ?? false, isBilling: address.isBilling ?? false, + isBroker: address.isBroker ?? false, + isDistributor: address.isDistributor ?? false, country: address.country ?? 'France', postalCode: address.postalCode ?? null, city: address.city ?? null, @@ -218,6 +223,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft { siteIris: (address.sites ?? []).map(s => s['@id']), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), billingEmail: address.billingEmail ?? null, + billingEmailSecondary: address.billingEmailSecondary ?? null, + hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '', } } diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index c7551f6..080f112 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,21 @@ 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). + // relationType : champ transitoire (non persiste cote back) qui porte + // l'intention UI « ce client depend d'un distributeur / courtier ». Il sert + // a la validation croisee serveur (RG-1.03 bis) : si une relation est choisie, + // la FK correspondante devient obligatoire -> 422 sur distributor / broker. + // Sans equivalent derivable cote back (FK nullable), c'est la seule facon de + // rester sur « on soumet, le back tranche » plutot qu'une garde front-only. + return omitEmptyRequired({ companyName: main.companyName, categories: main.categoryIris, + relationType: main.relationType, distributor: main.relationType === 'distributeur' ? main.distributorIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null, triageService: main.triageService, - } + }, MAIN_REQUIRED_NON_NULLABLE_KEYS) } /** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ @@ -198,10 +212,13 @@ 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, + isBroker: address.isBroker, + isDistributor: address.isDistributor, country: address.country, postalCode: address.postalCode || null, city: address.city || null, @@ -211,16 +228,19 @@ export function buildAddressPayload( sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, - } + billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, + }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) } /** 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..2a26c9f 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -50,6 +50,18 @@ export function buildClientFormTabKeys( return keys } +/** + * Dernier onglet REMPLISSABLE d'un jeu d'onglets : le dernier qui n'est pas un + * placeholder (coquille). Role-aware sans regle ad hoc — il suffit de lui passer + * les `tabKeys` deja filtres par permission (l'onglet Comptabilite n'y figure que + * si accounting.view). Sa validation marque la fin de l'ajout (redirection liste). + */ +export function lastFillableTabKey(tabKeys: string[]): string | undefined { + return [...tabKeys].reverse().find( + key => !(CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key), + ) +} + /** * Codes de categorie « intermediaire » : un client dont la categorie est * Distributeur ou Courtier n'a ni relation amont (il EST le distributeur / @@ -81,6 +93,10 @@ export interface AddressFlagsDraft { isProspect: boolean isDelivery: boolean isBilling: boolean + /** Adresse Courtier — type autonome exclusif (comme isProspect). */ + isBroker: boolean + /** Adresse Distributeur — type autonome exclusif (comme isProspect). */ + isDistributor: boolean } /** Vrai si une chaine porte au moins un caractere non-espace. */ @@ -220,22 +236,30 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean { * drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules * combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08). */ -export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' +export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor' /** - * Mappe le type d'adresse choisi vers les trois drapeaux back. + * Mappe le type d'adresse choisi vers les cinq drapeaux back. * « Adresse + Facturation » = livraison ET facturation sur la meme adresse. + * Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste). */ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft { + const none: AddressFlagsDraft = { + isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false, + } switch (type) { case 'prospect': - return { isProspect: true, isDelivery: false, isBilling: false } + return { ...none, isProspect: true } case 'delivery': - return { isProspect: false, isDelivery: true, isBilling: false } + return { ...none, isDelivery: true } case 'billing': - return { isProspect: false, isDelivery: false, isBilling: true } + return { ...none, isBilling: true } case 'delivery_billing': - return { isProspect: false, isDelivery: true, isBilling: true } + return { ...none, isDelivery: true, isBilling: true } + case 'broker': + return { ...none, isBroker: true } + case 'distributor': + return { ...none, isDistributor: true } } } @@ -246,6 +270,8 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft { */ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null { if (flags.isProspect) return 'prospect' + if (flags.isBroker) return 'broker' + if (flags.isDistributor) return 'distributor' if (flags.isDelivery && flags.isBilling) return 'delivery_billing' if (flags.isDelivery) return 'delivery' if (flags.isBilling) return 'billing' @@ -358,3 +384,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/frontend/package-lock.json b/frontend/package-lock.json index 211699f..43f4fc0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "starseed-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.7.7", + "@malio/layer-ui": "^1.7.8", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -1866,9 +1866,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.7.7", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz", - "integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==", + "version": "1.7.8", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz", + "integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/frontend/package.json b/frontend/package.json index 2e1e916..a60e49b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@malio/layer-ui": "^1.7.7", + "@malio/layer-ui": "^1.7.8", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/migrations/Version20260609120000.php b/migrations/Version20260609120000.php new file mode 100644 index 0000000..1dfd16c --- /dev/null +++ b/migrations/Version20260609120000.php @@ -0,0 +1,81 @@ +addSql('ALTER TABLE client_address ADD COLUMN is_broker BOOLEAN DEFAULT FALSE NOT NULL'); + $this->addSql('ALTER TABLE client_address ADD COLUMN is_distributor BOOLEAN DEFAULT FALSE NOT NULL'); + + // Exclusivite miroir (filet de securite DBAL) : un type autonome interdit + // tout autre drapeau. Livraison + Facturation restent cumulables entre eux. + $this->addSql(<<<'SQL' + ALTER TABLE client_address + ADD CONSTRAINT chk_client_address_broker_exclusive + CHECK (NOT (is_broker = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_distributor = TRUE))) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE client_address + ADD CONSTRAINT chk_client_address_distributor_exclusive + CHECK (NOT (is_distributor = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_broker = TRUE))) + SQL); + + $this->comment('client_address', 'is_broker', 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.'); + $this->comment('client_address', 'is_distributor', 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.'); + + // Le commentaire de table mentionnait seulement prospect/livraison/facturation : + // on y ajoute les types autonomes Courtier / Distributeur (cf. ColumnCommentsCatalog). + $this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('COMMENT ON TABLE client_address IS $_$Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).$_$'); + $this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_broker_exclusive'); + $this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_distributor_exclusive'); + $this->addSql('ALTER TABLE client_address DROP COLUMN is_distributor'); + $this->addSql('ALTER TABLE client_address DROP COLUMN is_broker'); + } + + /** + * Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour + * eviter tout echappement. + */ + private function comment(string $table, string $column, string $description): void + { + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + '"'.str_replace('"', '""', $table).'"', + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/migrations/Version20260609140000.php b/migrations/Version20260609140000.php new file mode 100644 index 0000000..cc04c66 --- /dev/null +++ b/migrations/Version20260609140000.php @@ -0,0 +1,51 @@ +addSql('ALTER TABLE client_address ADD COLUMN billing_email_secondary VARCHAR(180) DEFAULT NULL'); + + $this->comment('client_address', 'billing_email_secondary', '2e email de facturation, optionnel (max 2). Interdit hors facturation (validateBillingEmailPresence), normalise en minuscules (RG-1.21).'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE client_address DROP COLUMN billing_email_secondary'); + } + + /** + * Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour + * eviter tout echappement. + */ + private function comment(string $table, string $column, string $description): void + { + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + '"'.str_replace('"', '""', $table).'"', + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 387f94f..c8a033d 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Client (M1 Commercial) — entite racine du repertoire clients. Porte le @@ -171,6 +172,17 @@ class Client implements TimestampableInterface, BlamableInterface #[Groups(['client:read', 'client:write:main'])] private bool $triageService = false; + // Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI + // « ce client depend d'un distributeur / courtier ». Write-only (groupe + // d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en + // sortie). Sert exclusivement a la validation croisee validateRelationName : + // si une relation est choisie, la FK correspondante (distributor / broker) + // devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois + // l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture). + #[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')] + #[Groups(['client:write:main'])] + private ?string $relationType = null; + // RG : au moins une categorie (Count min 1). M2M vers Category via le contrat // CategoryInterface (resolve_target_entities -> Category). /** @var Collection */ @@ -333,6 +345,45 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + public function getRelationType(): ?string + { + return $this->relationType; + } + + public function setRelationType(?string $relationType): static + { + $this->relationType = $relationType; + + return $this; + } + + /** + * RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un + * distributeur / courtier » via le champ transitoire relationType), la FK + * correspondante est obligatoire. Le back ne peut pas deviner cette intention + * a partir des seules FK nullable (distributor=null ne distingue pas « pas de + * relation » de « relation choisie sans nom »), d'ou relationType qui la porte. + * Violation portee sur distributor / broker (champ fautif cote formulaire), de + * sorte que useFormErrors la mappe inline sous le bon select (ERP-101). + */ + #[Assert\Callback] + public function validateRelationName(ExecutionContextInterface $context): void + { + if ('distributeur' === $this->relationType && null === $this->distributor) { + $context->buildViolation('Le nom du distributeur est obligatoire.') + ->atPath('distributor') + ->addViolation() + ; + } + + if ('courtier' === $this->relationType && null === $this->broker) { + $context->buildViolation('Le nom du courtier est obligatoire.') + ->atPath('broker') + ->addViolation() + ; + } + } + public function isTriageService(): bool { return $this->triageService; diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 99a998f..98d0a45 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:write'])] private bool $isBilling = false; + // Adresse Courtier / Distributeur : types autonomes (comme Prospection), + // exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD + // chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive). + // Lecture portee par le getter + SerializedName (meme pattern que isProspect). + #[ORM\Column(name: 'is_broker', options: ['default' => false])] + #[Groups(['client_address:write'])] + private bool $isBroker = false; + + #[ORM\Column(name: 'is_distributor', options: ['default' => false])] + #[Groups(['client_address:write'])] + private bool $isDistributor = false; + #[ORM\Column(length: 80, options: ['default' => 'France'])] #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] @@ -166,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:read', 'client_address:write'])] private ?string $billingEmail = null; + // 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). + // Comme le principal : interdit hors facturation (validateBillingEmailPresence), + // mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor. + #[ORM\Column(length: 180, nullable: true)] + #[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $billingEmailSecondary = null; + #[ORM\Column(options: ['default' => 0])] #[Groups(['client_address:read', 'client_address:write'])] private int $position = 0; @@ -223,6 +244,48 @@ 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 && !$this->isBroker && !$this->isDistributor) { + $context->buildViolation('Le type d\'adresse est obligatoire.') + ->atPath('isProspect') + ->addViolation() + ; + } + } + + /** + * Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la + * Prospection) : exclusifs de tout autre usage (Livraison / Facturation / + * Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK + * chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive. + * Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »). + */ + #[Assert\Callback] + public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void + { + if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) { + $context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.') + ->atPath('isProspect') + ->addViolation() + ; + } + + if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) { + $context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.') + ->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 @@ -254,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface ->addViolation() ; } + + // Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il + // n'a de sens que sur une adresse de facturation. + $hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary); + if (!$this->isBilling && $hasSecondaryEmail) { + $context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.') + ->atPath('billingEmailSecondary') + ->addViolation() + ; + } } /** @@ -343,6 +416,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface return $this; } + #[Groups(['client_address:read'])] + #[SerializedName('isBroker')] + public function isBroker(): bool + { + return $this->isBroker; + } + + public function setIsBroker(bool $isBroker): static + { + $this->isBroker = $isBroker; + + return $this; + } + + #[Groups(['client_address:read'])] + #[SerializedName('isDistributor')] + public function isDistributor(): bool + { + return $this->isDistributor; + } + + public function setIsDistributor(bool $isDistributor): static + { + $this->isDistributor = $isDistributor; + + return $this; + } + public function getCountry(): string { return $this->country; @@ -415,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface return $this; } + public function getBillingEmailSecondary(): ?string + { + return $this->billingEmailSecondary; + } + + public function setBillingEmailSecondary(?string $billingEmailSecondary): static + { + $this->billingEmailSecondary = $billingEmailSecondary; + + return $this; + } + public function getPosition(): int { return $this->position; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php index 01249e9..6fc350d 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php @@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface private function normalize(ClientAddress $address): void { $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); + $address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary())); } } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 0c85547..b323d17 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -219,19 +219,22 @@ final class ColumnCommentsCatalog ] + self::timestampableBlamableComments(), 'client_address' => [ - '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', - 'id' => 'Identifiant interne auto-incremente.', - 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', - 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', - 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', - 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', - 'country' => 'Pays de l adresse — defaut France.', - 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', - 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', - 'street' => 'Numero et voie de l adresse.', - 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', - 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', - 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', + '_table' => 'Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', + 'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.', + 'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.', + 'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.', + 'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.', + 'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', + 'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).', + 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), 'client_address_site' => [ diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php index d5f5241..83b834e 100644 --- a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php @@ -137,6 +137,27 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase return $client; } + /** + * Indexe les violations d'un corps de reponse 422 par propertyPath. Permet + * d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422 + * orthogonal) : un test qui se contente du code 422 passerait meme si la RG + * visee etait cassee pour une autre raison. Mutualise ici (et non dans la + * sous-classe Supplier) pour etre accessible a tous les tests Commercial. + * + * @param array $body corps decode de la reponse (toArray(false)) + * + * @return array propertyPath => message + */ + protected function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } + private function cleanupCommercialTestData(): void { $em = $this->getEm(); diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php index 4cad97a..e7c5b97 100644 --- a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -298,26 +298,6 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase return $this->referential(Bank::class, $code); } - /** - * Indexe les violations d'un corps de reponse 422 par propertyPath. Permet - * d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422 - * orthogonal) : un test qui se contente du code 422 passerait meme si la RG - * visee etait cassee pour une autre raison. - * - * @param array $body corps decode de la reponse (toArray(false)) - * - * @return array propertyPath => message - */ - protected function violationsByPath(array $body): array - { - $byPath = []; - foreach ($body['violations'] ?? [] as $v) { - $byPath[$v['propertyPath']] = $v['message']; - } - - return $byPath; - } - /** * Recupere un referentiel comptable seede (CommercialReferentialFixtures) par * code. Echoue explicitement si absent (fixtures non chargees). diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 93eb41c..9e8f8fa 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', @@ -187,6 +189,62 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2). + */ + public function testBillingAddressAcceptsTwoEmails(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Billing Two Emails'); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => true, + 'billingEmail' => 'facturation@test.fr', + 'billingEmailSecondary' => 'compta@test.fr', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + /** + * ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que + * sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary. + */ + public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Secondary Email Non Billing'); + $category = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'isDelivery' => true, + 'billingEmailSecondary' => 'compta@test.fr', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($body); + self::assertArrayHasKey('billingEmailSecondary', $byPath); + } + /** * RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * avec violation sur le champ `categories`. @@ -201,6 +259,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 +288,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 +313,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 +338,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 +363,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 +374,115 @@ 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 = $this->violationsByPath($body); + self::assertArrayHasKey('isProspect', $byPath); + self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']); + } + + /** + * Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes + * comme types autonomes (avec site + categorie). is_broker / is_distributor. + */ + public function testBrokerAddressAccepted(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Broker Type'); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBroker' => true, + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testDistributorAddressAccepted(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Distributor Type'); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isDistributor' => true, + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + /** + * Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec + * un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le + * select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive. + */ + public function testExclusiveAddressTypeRejected(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Broker Mix'); + $category = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'isBroker' => true, + 'isDelivery' => true, + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($body); + self::assertArrayHasKey('isProspect', $byPath); + self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']); + } + /** * Retourne l'IRI du premier site seede (fixtures Sites). */ diff --git a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php index 165954f..ed16568 100644 --- a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php +++ b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php @@ -85,4 +85,77 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase self::assertNotNull($persisted); self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName()); } + + /** + * RG-1.03 bis : declarer une relation « depend d'un distributeur » + * (relationType, champ transitoire) sans renseigner la FK distributor doit + * produire une 422 portee sur `distributor`. Le back ne peut pas deviner + * l'intention depuis la seule FK nullable (distributor=null = client + * independant), d'ou relationType qui la transporte. + */ + public function testRelationDistributeurSansDistributeurEst422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Relation Sans Distrib SARL', + 'categories' => ['/api/categories/'.$cat->getId()], + 'relationType' => 'distributeur', + ], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($body); + self::assertArrayHasKey('distributor', $byPath); + self::assertSame('Le nom du distributeur est obligatoire.', $byPath['distributor']); + } + + /** Idem courtier : relationType=courtier sans broker -> 422 portee sur `broker`. */ + public function testRelationCourtierSansCourtierEst422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Relation Sans Courtier SARL', + 'categories' => ['/api/categories/'.$cat->getId()], + 'relationType' => 'courtier', + ], + ])->toArray(false); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($body); + self::assertArrayHasKey('broker', $byPath); + self::assertSame('Le nom du courtier est obligatoire.', $byPath['broker']); + } + + /** + * Le champ transitoire relationType ne casse pas la creation nominale : avec + * la FK correspondante renseignee, le client se cree (201) et relationType + * n'est jamais serialise en sortie (write-only, aucun groupe de lecture). + */ + public function testRelationDistributeurAvecDistributeurEst201(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $distributor = $this->seedClient('Distrib Cible', false, 'DISTRIBUTEUR'); + + $data = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Relation Ok SARL', + 'categories' => ['/api/categories/'.$cat->getId()], + 'relationType' => 'distributeur', + 'distributor' => '/api/clients/'.$distributor->getId(), + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertArrayNotHasKey('relationType', $data); + } } diff --git a/tests/Module/Commercial/Api/ClientSerializationContractTest.php b/tests/Module/Commercial/Api/ClientSerializationContractTest.php index 7a3e345..17410f8 100644 --- a/tests/Module/Commercial/Api/ClientSerializationContractTest.php +++ b/tests/Module/Commercial/Api/ClientSerializationContractTest.php @@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas self::assertArrayHasKey('isProspect', $address); self::assertArrayHasKey('isDelivery', $address); self::assertArrayHasKey('isBilling', $address); + // Memes garanties pour les types Courtier / Distributeur (ERP-119, meme + // pattern getter + SerializedName). + self::assertArrayHasKey('isBroker', $address); + self::assertArrayHasKey('isDistributor', $address); // L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06). // Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true). self::assertFalse($address['isProspect']); self::assertTrue($address['isDelivery']); self::assertTrue($address['isBilling']); + self::assertFalse($address['isBroker']); + self::assertFalse($address['isDistributor']); } // === #80 — Gating des RIB par accounting.view === diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 5b4ecca..08ef6b7 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -89,10 +89,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase ]); self::assertResponseStatusCodeSame(422); - $byPath = []; - foreach ($response->toArray(false)['violations'] ?? [] as $v) { - $byPath[$v['propertyPath']] = $v['message']; - } + $byPath = $this->violationsByPath($response->toArray(false)); self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).'); self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); @@ -135,10 +132,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase ]); self::assertResponseStatusCodeSame(422); - $byPath = []; - foreach ($response->toArray(false)['violations'] ?? [] as $v) { - $byPath[$v['propertyPath']] = $v['message']; - } + $byPath = $this->violationsByPath($response->toArray(false)); self::assertArrayHasKey('email', $byPath); self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); } @@ -237,6 +231,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 +253,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 +283,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 +310,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', @@ -382,10 +380,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase ]); self::assertResponseStatusCodeSame(422); - $byPath = []; - foreach ($response->toArray(false)['violations'] ?? [] as $v) { - $byPath[$v['propertyPath']] = $v['message']; - } + $byPath = $this->violationsByPath($response->toArray(false)); self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).'); self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);