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
@@ -52,3 +52,71 @@ describe('useClientFormErrors', () => {
expect(f.addressErrors.value[0]).toBeUndefined()
})
})
// Construit une erreur facon useApi : 422 avec violations Hydra.
function http422(path: string, message: string) {
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
}
/**
* `submitRows` factorise la soumission d'une collection de blocs (contacts /
* adresses / RIB) : on tente TOUS les blocs et on collecte les erreurs par index
* sans stopper au premier echec (ERP-110 / ERP-101).
*/
describe('useClientFormErrors.submitRows', () => {
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const seen: number[] = []
const onUnmapped = vi.fn()
const saveRow = async (_row: unknown, index: number) => {
seen.push(index)
if (index === 1) throw http422('email', 'Email invalide')
}
const hasError = await submitRows(
[{ a: 0 }, { a: 1 }, { a: 2 }],
contactErrors,
saveRow,
onUnmapped,
)
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
expect(hasError).toBe(true)
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
expect(contactErrors.value[0]).toBeUndefined()
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
})
it('delegue le fallback onUnmappedError pour une erreur non mappable et marque hasError', async () => {
const { ribErrors, submitRows } = useClientFormErrors()
const onUnmapped = vi.fn()
const hasError = await submitRows(
[{ a: 0 }],
ribErrors,
async () => { throw { response: { status: 500, _data: {} } } },
onUnmapped,
)
expect(hasError).toBe(true)
expect(onUnmapped).toHaveBeenCalledTimes(1)
expect(ribErrors.value[0]).toBeUndefined()
})
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const saved: number[] = []
const hasError = await submitRows(
[{ skip: true }, { skip: false }],
contactErrors,
async (_row, index) => { saved.push(index) },
vi.fn(),
(row: { skip: boolean }) => row.skip,
)
expect(saved).toEqual([1])
expect(hasError).toBe(false)
})
})
@@ -43,6 +43,44 @@ export function useClientFormErrors() {
return false
}
/**
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides
* (non remplis). Retourne true si au moins un bloc a echoue (le caller ne valide
* alors pas l'onglet et n'affiche pas de toast succes).
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
// L'index reste borne par rows.length : la ligne existe forcement.
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
return {
mainErrors,
informationErrors,
@@ -51,5 +89,6 @@ export function useClientFormErrors() {
addressErrors,
ribErrors,
mapRowError,
submitRows,
}
}