fix(commercial) : validation tous-blocs des onglets collection client + fix 500 NonUniqueResult (ERP-110) (#61)
Auto Tag Develop / tag (push) Successful in 8s

## Contexte (ERP-110, dérivé de ERP-107)

Sur les onglets à blocs dynamiques d'un client (Contacts / Adresses / RIB), le POST d'une sous-ressource sur un client ayant déjà **≥2 enfants** renvoyait une **500 `NonUniqueResultException`**, court-circuitant la validation (aucune 422 par champ).

## Cause racine

Au stade « read » du POST, le `Link` `toProperty` faisait résoudre la collection enfant via `getOneOrNullResult()` (`ItemProvider`) : `SELECT o FROM ClientContact o INNER JOIN o.client c WHERE c.id = :clientId`. Dès 2 enfants → `NonUniqueResult` → 500 **avant** la déserialisation/validation. Les 3 sous-ressources partageaient la même config (même bug latent). Cause secondaire front : la boucle de soumission s'arrêtait au 1er bloc en erreur (`return` dans le `catch`).

## Correctif

**Back** — `read: false` sur les 3 opérations `Post` (`ClientContact` / `ClientAddress` / `ClientRib`) : le parent est déjà rattaché manuellement par le `*Processor::linkParent`. Les 3 `linkParent` sont durcis (`NotFoundHttpException` si parent absent → **404 préservé**, sinon régression 500 au persist sur `client_id NOT NULL`).

**Front** — nouveau helper `useClientFormErrors().submitRows()` qui tente **tous** les blocs et collecte les erreurs 422 par index (`hasError`), branché sur les 6 sites (`new.vue` + `edit.vue` × contacts/adresses/RIB). Feedback **inline seul** : pas de toast récap, pas de toast succès tant qu'un bloc reste en erreur.

## Tests

- Back : non-régression POST contact/adresse/RIB sur client déjà peuplé (≥2 enfants) → 201, + 422 `propertyPath=email` (validation atteinte). Rouge avant fix (500), vert après.
- Front : `submitRows` (Vitest) — tente tous les blocs, mappe les erreurs par index, n'arrête pas au 1er échec, délègue le fallback non-422, saute les blocs filtrés.

## Vérifications

- `make test` : 474/474 OK
- `make php-cs-fixer-allow-risky` : 0 fichier à corriger
- `make nuxt-test` : 219/219 OK

> Golden path manuel navigateur non exécuté (couvert par les tests automatisés).

---------

Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #61
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #61.
This commit is contained in:
2026-06-04 14:06:03 +00:00
committed by Autin
parent c437bc52a2
commit e139d234a9
20 changed files with 967 additions and 202 deletions
@@ -1,17 +1,36 @@
import { describe, it, expect } from 'vitest'
import {
addressFlagsFromType,
addressTypeFromFlags,
applyProspectExclusivity,
buildClientFormTabKeys,
canSelectDeliveryOrBilling,
canSelectProspect,
hasAllRequiredAccountingFields,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
isRibRequiredForPaymentType,
type ContactDraft,
type ContactFillableDraft,
} from '../clientFormRules'
/** Bloc contact totalement vide (amorce par defaut). */
function blankContact(): ContactFillableDraft {
return {
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
}
}
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
expect(buildClientFormTabKeys(true)).toContain('accounting')
@@ -59,6 +78,49 @@ describe('isContactNamed (RG-1.05)', () => {
})
})
describe('isBlankRow (primitive : toutes les valeurs vides)', () => {
it('vrai si toutes les valeurs sont nulles / vides / espaces', () => {
expect(isBlankRow([null, undefined, '', ' '])).toBe(true)
expect(isBlankRow([])).toBe(true)
})
it('faux des qu une valeur porte un caractere non-espace', () => {
expect(isBlankRow([null, 'x', ''])).toBe(false)
})
})
describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => {
it('vrai si label / bic / iban sont tous vides', () => {
expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true)
expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true)
})
it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => {
expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false)
})
it('faux si seul le libelle est saisi', () => {
expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false)
})
})
describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => {
it('vrai si aucun champ saisissable n est rempli', () => {
expect(isContactBlank(blankContact())).toBe(true)
expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true)
})
it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => {
expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false)
})
it('faux si seul un telephone, une fonction ou un nom est saisi', () => {
expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false)
expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false)
})
})
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
it('faux sur une liste vide', () => {
expect(hasAtLeastOneValidContact([])).toBe(false)
@@ -137,6 +199,32 @@ describe('isBillingEmailRequired (RG-1.11)', () => {
})
})
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 })
})
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 retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
})
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
}
})
})
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
it('banque obligatoire si VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
@@ -150,3 +238,36 @@ describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
const complete = {
siren: '123456789',
accountNumber: '00012345678',
nTva: 'FR12345678901',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
}
it('vrai quand les six champs obligatoires sont remplis', () => {
expect(hasAllRequiredAccountingFields(complete)).toBe(true)
})
it('faux si un champ est manquant (null ou vide apres trim)', () => {
expect(hasAllRequiredAccountingFields({ ...complete, siren: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, accountNumber: ' ' })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, tvaModeIri: null })).toBe(false)
expect(hasAllRequiredAccountingFields({ ...complete, paymentTypeIri: null })).toBe(false)
})
it('faux quand tout est vide (onglet non rempli)', () => {
expect(hasAllRequiredAccountingFields({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
})).toBe(false)
})
})
@@ -86,6 +86,58 @@ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
return contacts.some(isContactNamed)
}
/**
* Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null /
* undefined / espaces uniquement). Sert a detecter un bloc de collection
* totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee
* n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot
* que d'etre saute silencieusement.
*/
export function isBlankRow(values: (string | null | undefined)[]): boolean {
return values.every(value => !isFilled(value))
}
/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */
export interface ContactFillableDraft extends ContactDraft {
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
}
/**
* Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc
* d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom
* (email / telephone / fonction seul) : ce dernier doit etre soumis pour
* declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline.
*/
export function isContactBlank(contact: ContactFillableDraft): boolean {
return isBlankRow([
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.phoneSecondary,
contact.email,
])
}
/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */
export interface RibFillableDraft {
label: string | null
bic: string | null
iban: string | null
}
/**
* Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex.
* IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422
* NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement.
*/
export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban])
}
/**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -135,6 +187,45 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
return flags.isBilling
}
/**
* Type d'adresse expose a l'utilisateur (Select unique remplacant les trois
* cases a cocher). Sucre purement front : le back continue de recevoir les
* 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'
/**
* Mappe le type d'adresse choisi vers les trois drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
*/
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
switch (type) {
case 'prospect':
return { isProspect: true, isDelivery: false, isBilling: false }
case 'delivery':
return { isProspect: false, isDelivery: true, isBilling: false }
case 'billing':
return { isProspect: false, isDelivery: false, isBilling: true }
case 'delivery_billing':
return { isProspect: false, isDelivery: true, isBilling: true }
}
}
/**
* Reconstruit le type d'adresse a partir des drapeaux (consultation / edition
* d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau
* n'est positionne — le Select reste alors a saisir (et bloque la validation).
*/
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing'
return null
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -156,3 +247,32 @@ export function isBankRequiredForPaymentType(code: string | null | undefined): b
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
}
/**
* RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires
* pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de
* reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 /
* RG-1.13) et sont evalues a part. Miroir front du
* ClientAccountingCompletenessValidator : meme gate que les onglets Contact /
* Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet).
*/
export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean {
const filled = (v: string | null): boolean => v !== null && v.trim() !== ''
return filled(accounting.siren)
&& filled(accounting.accountNumber)
&& filled(accounting.nTva)
&& filled(accounting.tvaModeIri)
&& filled(accounting.paymentDelayIri)
&& filled(accounting.paymentTypeIri)
}