fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s
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:
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
|
||||
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
|
||||
|
||||
@@ -545,6 +545,9 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.address.value.id).toBe(88)
|
||||
expect(form.isValidated('addresses')).toBe(true)
|
||||
// ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix
|
||||
// (dernier onglet), sans étape bloquante par Contacts.
|
||||
expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1)
|
||||
})
|
||||
|
||||
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
|
||||
@@ -577,7 +580,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
||||
describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphones', () => {
|
||||
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
|
||||
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
@@ -586,15 +589,6 @@ describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléph
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
|
||||
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
|
||||
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
|
||||
})
|
||||
|
||||
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
|
||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
@@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
|
||||
it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc n'est pas nommé.
|
||||
// addContact est un no-op tant que le bloc est totalement vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
|
||||
// ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit
|
||||
// désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée.
|
||||
const first = form.contacts.value[0]
|
||||
if (first) first.jobTitle = 'Acheteur'
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Un nom (ou prénom) débloque l'ajout.
|
||||
if (first) first.lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
@@ -686,21 +675,15 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => {
|
||||
const form = createdForm()
|
||||
|
||||
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contacts')).toBe(false)
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.isValidated('contacts')).toBe(true)
|
||||
})
|
||||
|
||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('useCarriersRepository', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,5 +66,6 @@ export interface CarrierFilters {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useCarriersRepository() {
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers', defaultItemsPerPage: 25 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user