Merge branch 'develop' into feat/erp-154-upload
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m33s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s

This commit is contained in:
2026-06-15 15:11:00 +00:00
10 changed files with 378 additions and 142 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.122' app.version: '0.1.123'
@@ -157,12 +157,16 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock <ClientContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`" :key="contact.id ?? `new-${index}`"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -199,7 +203,7 @@
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -304,7 +308,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -440,6 +444,7 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([]) const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([]) const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false) const mainSubmitting = ref(false)
const tabSubmitting = ref(false) const tabSubmitting = ref(false)
@@ -754,32 +755,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => { askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
const removed = contacts.value[index] rows: contacts.value,
if (removed?.id != null) removedContactIds.value.push(removed.id) errors: contactErrors.value,
contacts.value.splice(index, 1) index,
contactErrors.value.splice(index, 1) endpoint: '/client_contacts',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (contacts.value.length === 0) contacts.value.push(emptyContact()) makeEmpty: emptyContact,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis * Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la * Strictement scope a la collection contacts (endpoints client_contact dedies). La
* collection contacts (endpoints client_contact dedies). * suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des // 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 // 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 // les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
@@ -836,14 +836,15 @@ function addAddress(): void {
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => { askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
const removed = addresses.value[index] rows: addresses.value,
if (removed?.id != null) removedAddressIds.value.push(removed.id) errors: addressErrors.value,
addresses.value.splice(index, 1) index,
addressErrors.value.splice(index, 1) endpoint: '/client_addresses',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) makeEmpty: emptyAddress,
}) onError: showError,
}))
} }
function onAddressDegraded(): void { function onAddressDegraded(): void {
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
}) })
} }
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
const hasError = await submitRows( const hasError = await submitRows(
addresses.value, addresses.value,
@@ -937,29 +933,32 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => { askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
const removed = ribs.value[index] rows: ribs.value,
if (removed?.id != null) removedRibIds.value.push(removed.id) errors: ribErrors.value,
ribs.value.splice(index, 1) index,
ribErrors.value.splice(index, 1) endpoint: '/client_ribs',
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (ribs.value.length === 0) ribs.value.push(emptyRib()) makeEmpty: emptyRib,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back * back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires. * persiste) sur le PATCH scalaires.
* *
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais * coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES * re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement * sinon 403 sur tout le payload).
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -156,12 +156,16 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock <ClientContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -198,7 +202,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -303,7 +307,7 @@
> >
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -417,6 +421,7 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -126,12 +126,16 @@
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock <SupplierContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`" :key="contact.id ?? `new-${index}`"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -168,7 +172,7 @@
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -273,7 +277,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -407,6 +411,7 @@ import {
type SupplierRibFormDraft, type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([]) const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([]) const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false) const mainSubmitting = ref(false)
const tabSubmitting = ref(false) const tabSubmitting = ref(false)
@@ -653,32 +654,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
const removed = contacts.value[index] rows: contacts.value,
if (removed?.id != null) removedContactIds.value.push(removed.id) errors: contactErrors.value,
contacts.value.splice(index, 1) index,
contactErrors.value.splice(index, 1) endpoint: '/supplier_contacts',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (contacts.value.length === 0) contacts.value.push(emptyContact()) makeEmpty: emptyContact,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis * Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la * Strictement scope a la collection contacts (endpoints supplier_contact dedies).
* collection contacts (endpoints supplier_contact dedies). * La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des // RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom). // amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c)) const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
@@ -726,14 +726,15 @@ function addAddress(): void {
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
const removed = addresses.value[index] rows: addresses.value,
if (removed?.id != null) removedAddressIds.value.push(removed.id) errors: addressErrors.value,
addresses.value.splice(index, 1) index,
addressErrors.value.splice(index, 1) endpoint: '/supplier_addresses',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) makeEmpty: emptyAddress,
}) onError: showError,
}))
} }
function onAddressDegraded(): void { function onAddressDegraded(): void {
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
}) })
} }
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows( const hasError = await submitRows(
addresses.value, addresses.value,
addressErrors, addressErrors,
@@ -826,15 +822,18 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
const removed = ribs.value[index] rows: ribs.value,
if (removed?.id != null) removedRibIds.value.push(removed.id) errors: ribErrors.value,
ribs.value.splice(index, 1) index,
ribErrors.value.splice(index, 1) endpoint: '/supplier_ribs',
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (ribs.value.length === 0) ribs.value.push(emptyRib()) makeEmpty: emptyRib,
}) onError: showError,
}))
} }
/** /**
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le * cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. * back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* *
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais * coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES * re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le * sinon 403 sur tout le payload).
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') }) toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -121,12 +121,16 @@
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock <SupplierContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')" :readonly="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -163,7 +167,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')" :readonly="isValidated('addresses')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -267,7 +271,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -380,6 +384,7 @@ import {
type SupplierRibFormDraft, type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -1,6 +1,7 @@
import { computed, reactive, ref, type Ref } from 'vue' import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors' import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api' import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import { import {
emptyProviderAccounting, emptyProviderAccounting,
emptyProviderAddress, emptyProviderAddress,
@@ -73,6 +74,16 @@ export function useProviderForm() {
// Erreurs de validation par champ (ERP-101) du formulaire principal. // Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors() const mainErrors = useFormErrors()
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('technique.providers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
})
}
// ── Etat du prestataire cree ──────────────────────────────────────────── // ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null) const providerId = ref<number | null>(null)
const mainLocked = ref(false) const mainLocked = ref(false)
@@ -317,9 +328,18 @@ export function useProviderForm() {
} }
} }
function removeContact(index: number): void { // ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
contacts.value.splice(index, 1) // confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
contactErrors.value.splice(index, 1) async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/provider_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact,
onError: notifyRemovalError,
})
} }
/** /**
@@ -387,9 +407,17 @@ export function useProviderForm() {
} }
} }
function removeAddress(index: number): void { // ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
addresses.value.splice(index, 1) async function removeAddress(index: number): Promise<void> {
addressErrors.value.splice(index, 1) await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/provider_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress,
onError: notifyRemovalError,
})
} }
/** /**
@@ -479,13 +507,18 @@ export function useProviderForm() {
} }
} }
function removeRib(index: number): void { // ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
ribs.value.splice(index, 1) // du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
ribErrors.value.splice(index, 1) async function removeRib(index: number): Promise<void> {
// Garde au moins un bloc RIB visible (sous LCR). await removeCollectionRow({
if (ribs.value.length === 0) { rows: ribs.value,
ribs.value.push(emptyProviderRib()) errors: ribErrors.value,
} index,
endpoint: '/provider_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib,
onError: notifyRemovalError,
})
} }
/** /**
@@ -62,11 +62,15 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock <ProviderContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -308,6 +312,7 @@ import {
emptyProviderRib, emptyProviderRib,
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -63,11 +63,15 @@
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). --> <!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock <ProviderContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -292,6 +296,7 @@ import {
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting' } from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest'
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
/**
* Tests de `removeCollectionRow` suppression d'une ligne de collection
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
* blocs).
*/
interface Row extends DeletableRow {
label?: string
}
function makeEmpty(): Row {
return { id: null, label: '' }
}
describe('removeCollectionRow', () => {
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).toHaveBeenCalledOnce()
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled()
})
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 1,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }])
})
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const error = { response: { status: 409 } }
const deleteRow = vi.fn().mockRejectedValue(error)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_ribs',
deleteRow, makeEmpty, onError,
})
expect(removed).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
expect(errors).toHaveLength(2)
})
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }]
const errors: Record<string, string>[] = [{}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError: vi.fn(),
})
expect(rows).toEqual([{ id: null, label: '' }])
})
})
/**
* Tests de `isRowRemovable` la poubelle d'un bloc n'apparait que s'il reste un
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
*/
describe('isRowRemovable', () => {
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
expect(isRowRemovable(rows, 0)).toBe(false)
expect(isRowRemovable(rows, 1)).toBe(false)
})
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
expect(isRowRemovable(rows, 0)).toBe(false)
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
expect(isRowRemovable(rows, 0)).toBe(true)
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('faux pour un unique bloc', () => {
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
})
})
+79
View File
@@ -0,0 +1,79 @@
/** Ligne de collection supprimable (contact / adresse / RIB). */
export interface DeletableRow {
id?: number | null
}
/**
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
*
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
* simple brouillon saisi mais pas valide) ;
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
* - on ne peut jamais supprimer son dernier bloc enregistre.
*/
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
return rows.some((row, i) => i !== index && row.id != null)
}
/** Options de {@link removeCollectionRow}. */
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
rows: T[]
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
errors: Record<string, string>[]
/** Index de la ligne a retirer. */
index: number
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
endpoint: string
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
deleteRow: (url: string) => Promise<unknown>
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void
}
/**
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
*
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
* tableau. On ne retire le bloc QUE si le serveur a confirme sinon le bloc
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
* LCR -> 409 back, RG-x.08).
*
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
* reactifs), le `splice` declenche donc la reactivite.
*
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
* `false` si la suppression serveur a echoue (bloc conserve).
*/
export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>,
): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index]
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) {
try {
await deleteRow(`${endpoint}/${removed.id}`)
}
catch (error) {
onError(error)
return false
}
}
rows.splice(index, 1)
errors.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (rows.length === 0) {
rows.push(makeEmpty())
}
return true
}