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

This commit is contained in:
2026-06-17 09:15:56 +02:00
parent c6259a96cd
commit ef996c3672
8 changed files with 902 additions and 9 deletions
@@ -0,0 +1,237 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:required="true"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
<div aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<MalioInputText
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
interface RefOption {
value: string
label: string
}
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyCarrierAddress } from '~/modules/transport/types/carrierForm'
import CarrierAddressBlock from '../CarrierAddressBlock.vue'
/**
* Tests de l'autocomplétion BAN du bloc Adresse transporteur (ERP-167) — réutilise
* `useAddressAutocomplete` (M1/M2/M3). On vérifie le NOMINAL (CP → ville) et le
* DÉGRADÉ (BAN indisponible → saisie libre + event `degraded`).
*/
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
const MalioInputTextStub = defineComponent({
name: 'MalioInputText',
props: { modelValue: { default: null }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'input-text', 'data-label': props.label, 'data-error': props.error })
},
})
const MalioSelectStub = defineComponent({
name: 'MalioSelect',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'select', 'data-label': props.label, 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, loading: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false } },
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-autocomplete', 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, {
props: {
modelValue: { ...emptyCarrierAddress(), ...overrides },
countryOptions: [{ value: 'France', label: 'France' }],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputText: MalioInputTextStub,
MalioSelect: MalioSelectStub,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
/** Récupère le composant MalioInputText d'un label donné. */
function inputTextByLabel(wrapper: ReturnType<typeof mountBlock>, label: string) {
return wrapper.findAllComponents(MalioInputTextStub).find(c => c.props('label') === label)
}
describe('CarrierAddressBlock — autocomplétion ville (BAN) NOMINAL', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('saisie d\'un CP à 5 chiffres → searchCity + peuple le select Ville', async () => {
searchCityMock.mockResolvedValueOnce([{ city: 'Poitiers', postalCode: '86000' }])
const wrapper = mountBlock()
const cp = inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')
cp?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(searchCityMock).toHaveBeenCalledWith('86000')
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
const options = JSON.parse(citySelect?.attributes('data-options') ?? '[]')
expect(options).toContain('Poitiers')
expect(wrapper.emitted('degraded')).toBeUndefined()
})
it('n\'interroge pas la BAN sous 5 chiffres', async () => {
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '860')
await flushPromises()
expect(searchCityMock).not.toHaveBeenCalled()
})
})
describe('CarrierAddressBlock — autocomplétion DÉGRADÉE', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('BAN ville indisponible → bascule en saisie libre + émet « degraded »', async () => {
searchCityMock.mockRejectedValueOnce(new Error('BAN indisponible'))
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
// En dégradé, la Ville devient un MalioInputText (plus de MalioSelect ville).
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
expect(citySelect).toBeUndefined()
expect(inputTextByLabel(wrapper, 'transport.carriers.form.address.city')).toBeDefined()
})
it('autocomplétion adresse : pas d\'appel BAN sous 3 caractères', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('autocomplétion adresse : émet « degraded » une seule fois malgré plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
})