feat(transport) : onglet contacts transporteur (ERP-168)

This commit is contained in:
2026-06-17 09:28:03 +02:00
parent ef996c3672
commit 5765ba7178
7 changed files with 487 additions and 5 deletions
@@ -37,6 +37,8 @@ vi.stubGlobal('useToast', () => ({
}))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact')
const { emptyCarrierContact } = await import('../../types/carrierForm')
describe('useCarrierForm', () => {
beforeEach(() => {
@@ -555,3 +557,127 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
expect(form.addresses.value).toHaveLength(1)
})
})
describe('carrierContact (util) — RG-4.08 + max 2 téléphones', () => {
it('isCarrierContactBlank : vrai si aucun champ, faux dès un champ rempli', () => {
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phonePrimary: '0102030405' })).toBe(false)
})
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
expect(body.phones).toEqual(['0102030405'])
})
it('buildCarrierContactPayload : phones = 2 numéros si secondaire révélé', () => {
const body = buildCarrierContactPayload({
...emptyCarrierContact(),
phonePrimary: '0102030405',
phoneSecondary: '0605040302',
hasSecondaryPhone: true,
})
expect(body.phones).toEqual(['0102030405', '0605040302'])
})
it('buildCarrierContactPayload : 2e numéro ignoré tant que non révélé', () => {
const body = buildCarrierContactPayload({
...emptyCarrierContact(),
phonePrimary: '0102030405',
phoneSecondary: '0605040302',
hasSecondaryPhone: false,
})
expect(body.phones).toEqual(['0102030405'])
})
})
describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
})
/** Transporteur créé, onglet Contacts accessible. */
function createdForm() {
const form = useCarrierForm()
form.carrierId.value = 7
return form
}
it('RG-4.08 : « + Nouveau contact » désactivé tant que le bloc est vide', () => {
const form = createdForm()
expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc est vide.
form.addContact()
expect(form.contacts.value).toHaveLength(1)
const first = form.contacts.value[0]
if (first) first.lastName = 'Doe'
expect(form.canAddContact.value).toBe(true)
form.addContact()
expect(form.contacts.value).toHaveLength(2)
})
it('submitContacts : POST des nouveaux contacts (phones tableau), capture id, finalise', async () => {
mockPost.mockResolvedValueOnce({ id: 55 })
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.firstName = 'Jean'; c.phonePrimary = '0102030405' }
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers/7/contacts')
expect(body).toMatchObject({ firstName: 'Jean', phones: ['0102030405'] })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.contacts.value[0]?.id).toBe(55)
expect(form.isValidated('contacts')).toBe(true)
})
it('submitContacts : PATCH des contacts existants sur /carrier_contacts/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.id = 55; c.lastName = 'Doe' }
await form.submitContacts(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
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.' }] },
},
})
const form = createdForm()
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)
})
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
mockDelete.mockResolvedValueOnce({})
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.id = 90; c.lastName = 'Doe' }
form.addContact()
const c2 = form.contacts.value[1]
if (c2) c2.firstName = 'Jean'
await form.removeContact(0)
expect(mockDelete).toHaveBeenCalledWith('/carrier_contacts/90', {}, { toast: false })
expect(form.contacts.value).toHaveLength(1)
})
})
@@ -5,13 +5,16 @@ import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyCarrierAddress,
emptyCarrierAddressCopy,
emptyCarrierContact,
emptyCarrierMain,
type CarrierAddressCopy,
type CarrierAddressFormDraft,
type CarrierContactFormDraft,
type CarrierMainDraft,
type CarrierMainResponse,
} from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
@@ -382,6 +385,85 @@ export function useCarrierForm() {
}
}
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// RG-4.08 : « + Nouveau contact » désactivé tant que le DERNIER bloc est vide
// (aucun champ rempli).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isCarrierContactBlank(last)
})
function addContact(): void {
if (canAddContact.value) {
contacts.value.push(emptyCarrierContact())
}
}
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/carrier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact,
onError: notifyRemovalError,
})
}
/**
* 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é.
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildCarrierContactPayload(contact)
if (contact.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
}
else {
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
}
},
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),
)
if (hasError) {
return false
}
completeTab('contacts')
return true
}
finally {
tabSubmitting.value = false
}
}
/**
* Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 /
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
@@ -481,6 +563,13 @@ export function useCarrierForm() {
addAddress,
removeAddress,
submitAddresses,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// actions
validateMainFront,
buildMainPayload,