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 -1
View File
@@ -527,7 +527,8 @@
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
"createSuccess": "Transporteur créé avec succès",
"integrateSuccess": "Transporteur QUALIMAT intégré"
"integrateSuccess": "Transporteur QUALIMAT intégré",
"addressSaved": "Adresse enregistrée"
},
"containerType": {
"BENNE": "Benne",
@@ -578,6 +579,23 @@
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
},
"address": {
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetComplement": "Adresse complémentaire",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"add": "Nouvelle adresse",
"remove": "Supprimer l'adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"confirmDelete": {
"title": "Supprimer ce bloc",
"message": "Cette suppression est définitive. Confirmer ?",
"cancel": "Annuler",
"confirm": "Supprimer"
}
}
}
@@ -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)
})
})
@@ -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,
}
}
@@ -170,7 +170,44 @@
</div>
</template>
<!-- Adresses / Contacts / Prix : contenu aux tickets suivants. -->
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="isQualimat || isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
(adresse copiée et persistée automatiquement). -->
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('transport.carriers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Contacts / Prix : contenu aux tickets suivants. -->
<template
v-for="key in placeholderTabs"
:key="key"
@@ -203,12 +240,37 @@
/>
</template>
</MalioModal>
<!-- Modal de confirmation de suppression (bloc adresse). -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="deleteConfirm.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.confirm')"
@click="runDeleteConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { debounce } from '~/shared/utils/debounce'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
@@ -218,6 +280,7 @@ interface SelectOption {
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
@@ -232,16 +295,26 @@ if (!can('transport.carriers.manage')) {
const {
main,
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
isLiot,
isQualimat,
certificationReadonly,
showCharteredFields,
showDischarge,
tabKeys,
activeTab,
unlockedIndex,
isValidated,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
submitMain,
applyQualimatSelection,
} = useCarrierForm()
@@ -335,8 +408,80 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
disabled: index > unlockedIndex.value,
})))
// Onglets dont le contenu arrive aux tickets suivants (tout sauf Qualimat).
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
// Onglets dont le contenu arrive aux tickets suivants (Contacts / Prix).
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat' && key !== 'addresses'))
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
/** Charge le referentiel pays (/api/countries) ; conserve France par defaut si echec. */
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>(
'/countries',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
}
catch {
// Reste sur le fallback France (non bloquant).
}
}
onMounted(() => {
loadCountries().catch(() => {})
})
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) {
return
}
addressDegradedNotified.value = true
toast.warning({
title: t('transport.carriers.toast.error'),
message: t('transport.carriers.form.address.degraded'),
})
}
/** Message d'erreur affichable (toast) extrait d'une erreur API — jamais undefined. */
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
}
// Modal de confirmation de suppression (bloc adresse).
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveAddress(index: number): void {
deleteConfirm.action = () => { void removeAddress(index) }
deleteConfirm.open = true
}
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
const confirmOpen = ref(false)
@@ -417,8 +562,18 @@ function goBack(): void {
router.push('/carriers')
}
/** Valide le formulaire principal (POST /carriers ; bascule geree par le composable). */
/**
* Valide le formulaire principal (POST /carriers ; bascule geree par le composable).
* RG-4.07 : pour un transporteur QUALIMAT, l'adresse copiee est persistee
* automatiquement (pas de bouton Valider dans l'onglet Adresses).
*/
async function onSubmitMain(): Promise<void> {
await submitMain()
const ok = await submitMain()
if (ok && isQualimat.value) {
await submitAddresses(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
}
}
</script>
@@ -65,6 +65,35 @@ export function emptyCarrierAddressCopy(): CarrierAddressCopy {
return { country: 'France', postalCode: '', city: '', street: '' }
}
/**
* Brouillon d'un bloc Adresse (onglet Adresses, ERP-167) — sous-ressource
* `CarrierAddress` (groupe `carrier:write:addresses`). Version SIMPLIFIÉE de
* l'adresse fournisseur (M2) / prestataire (M3) : pas de sites / catégories /
* contacts ni type d'adresse (les sites du M4 vivent dans l'onglet Prix).
*/
export interface CarrierAddressFormDraft {
/** Id serveur une fois l'adresse créée (null tant que non persistée). */
id: number | null
/** Pays (chaîne libre, défaut « France »). */
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
}
/** Brouillon d'adresse vide (pays France par défaut, RG-4.05). */
export function emptyCarrierAddress(): CarrierAddressFormDraft {
return {
id: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
}
}
/**
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
@@ -0,0 +1,35 @@
/**
* Helpers purs de l'onglet Adresse transporteur (M4 Transport, ERP-167) — miroir
* SIMPLIFIÉ de `providerAddress` (M3) / `SupplierAddressBlock` (M2), sans sites /
* catégories / contacts (les sites du M4 vivent dans l'onglet Prix). Testables
* sans Vue.
*/
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
/**
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
* (donc on autorise l'ajout d'un nouveau bloc) dès qu'elle porte un code postal,
* une ville ET une rue. Les RG conditionnelles (obligatoire si affrété) restent
* validées par le back (422 inline) — ce gate empêche seulement d'empiler des
* blocs vides.
*/
export function isCarrierAddressValid(address: CarrierAddressFormDraft): boolean {
return Boolean(address.postalCode?.trim() && address.city?.trim() && address.street?.trim())
}
/**
* Payload de la sous-ressource addresses (groupe `carrier:write:addresses`). Les
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
* renvoie une 422 par champ).
*/
export function buildCarrierAddressPayload(address: CarrierAddressFormDraft): Record<string, unknown> {
return {
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
}
}