fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s

Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).

## Contenu

- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).

## Tests

- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #139.
This commit is contained in:
2026-06-22 09:40:40 +00:00
committed by Autin
parent 6c938756cc
commit 5e15c1f69f
93 changed files with 2791 additions and 803 deletions
@@ -17,7 +17,7 @@ import {
type CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
import {
mapAddressToDraft,
@@ -416,6 +416,11 @@ export function useCarrierForm() {
})
}
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
/**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
@@ -488,6 +493,10 @@ export function useCarrierForm() {
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
}
completeTab('addresses')
// ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller
// l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix
// (Contacts reste accessible mais n'est plus une étape bloquante).
unlockedIndex.value = tabKeys.value.length - 1
return true
}
catch (error) {
@@ -511,12 +520,13 @@ export function useCarrierForm() {
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
// suffisent pas à ajouter un nouveau bloc).
// « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 :
// l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom,
// un seul champ rempli (fonction / téléphone / email) suffit pour empiler un
// bloc suivant (et évite d'accumuler des blocs totalement vides).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isCarrierContactNamed(last)
return last !== undefined && !isCarrierContactBlank(last)
})
function addContact(): void {
@@ -535,16 +545,18 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact,
onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
})
}
/**
* Valide l'onglet Contacts : POST des nouveaux contacts sur
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
* (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par
* ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves
* sont systématiquement ignorées (pas de contact vide créé) et un onglet sans
* aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix.
* Retourne true si l'onglet a été validé.
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
@@ -552,7 +564,6 @@ export function useCarrierForm() {
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
@@ -571,9 +582,9 @@ export function useCarrierForm() {
}
},
onError,
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
// Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) :
// un onglet sans aucun bloc rempli se finalise sans rien créer.
contact => contact.id === null && isCarrierContactBlank(contact),
)
if (hasError) {
return false
@@ -648,6 +659,7 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierPrice,
onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
})
}