feat(transport) : onglet adresses transporteur (ERP-167)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m7s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s

This commit is contained in:
2026-06-17 09:15:56 +02:00
parent 40fdded7e2
commit 6a69d7cd23
8 changed files with 902 additions and 9 deletions
@@ -19,13 +19,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockDelete = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
delete: mockDelete,
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
@@ -414,4 +415,126 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
certificationType: 'QUALIMAT',
})
})
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
const form = useCarrierForm()
form.main.name = 'Acme'
await form.applyQualimatSelection(QUALIMAT_ROW)
expect(form.addresses.value).toHaveLength(1)
expect(form.addresses.value[0]).toEqual({
id: null,
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '1 rue du Port',
streetComplement: null,
})
})
})
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
})
/** Transporteur créé, onglet Adresses accessible. */
function createdForm() {
const form = useCarrierForm()
form.carrierId.value = 7
return form
}
/** Remplit un bloc adresse complet (CP + ville + rue). */
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
const a = form.addresses.value[index]
if (a) {
a.postalCode = '86100'
a.city = 'Châtellerault'
a.street = '1 rue du Test'
}
}
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
const form = createdForm()
expect(form.canAddAddress.value).toBe(false)
form.addAddress()
expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète
fillAddress(form)
expect(form.canAddAddress.value).toBe(true)
form.addAddress()
expect(form.addresses.value).toHaveLength(2)
})
it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ id: 88 })
const form = createdForm()
fillAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers/7/addresses')
expect(body).toEqual({
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
streetComplement: null,
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.addresses.value[0]?.id).toBe(88)
expect(form.isValidated('addresses')).toBe(true)
})
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillAddress(form)
const first = form.addresses.value[0]
if (first) first.id = 88
await form.submitAddresses(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
})
it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire pour un transporteur affrété.' }] },
},
})
const form = createdForm()
fillAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(false)
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
expect(form.isValidated('addresses')).toBe(false)
})
it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => {
mockDelete.mockResolvedValueOnce({})
const form = createdForm()
fillAddress(form)
const first = form.addresses.value[0]
if (first) first.id = 88
form.addAddress()
fillAddress(form, 1)
await form.removeAddress(0)
expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false })
expect(form.addresses.value).toHaveLength(1)
})
})
@@ -1,12 +1,17 @@
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyCarrierAddress,
emptyCarrierAddressCopy,
emptyCarrierMain,
type CarrierAddressCopy,
type CarrierAddressFormDraft,
type CarrierMainDraft,
type CarrierMainResponse,
} from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
@@ -52,6 +57,7 @@ export function useCarrierForm() {
const carrierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
@@ -255,6 +261,127 @@ export function useCarrierForm() {
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
}
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
})
}
/**
* 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
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
*/
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++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
}
else {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const addressErrors = ref<Record<string, string>[]>([])
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isCarrierAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) {
addresses.value.push(emptyCarrierAddress())
}
}
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
async function removeAddress(index: number): Promise<void> {
await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/carrier_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierAddress,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Adresses : POST des nouvelles adresses sur
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
* validé (avancé/terminé).
*/
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildCarrierAddressPayload(address)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
}
},
onError,
)
if (hasError) {
return false
}
completeTab('addresses')
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),
@@ -273,6 +400,16 @@ export function useCarrierForm() {
city: row.city ?? '',
street: row.address ?? '',
}
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
// QUALIMAT survit, les champs restent éditables — § 2.5).
addresses.value = [{
id: null,
country: 'France',
postalCode: row.postalCode || null,
city: row.city || null,
street: row.address || null,
streetComplement: null,
}]
if (carrierId.value === null) {
return true
@@ -320,6 +457,7 @@ export function useCarrierForm() {
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// affichage conditionnel
isLiot,
@@ -335,6 +473,13 @@ export function useCarrierForm() {
validated,
editMode,
isValidated,
// adresses
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
// actions
validateMainFront,
buildMainPayload,
@@ -342,5 +487,6 @@ export function useCarrierForm() {
patchCarrier,
applyQualimatSelection,
completeTab,
submitRows,
}
}