From ef996c3672db33b930a76f3f54dfa4620423dc86 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 09:15:56 +0200 Subject: [PATCH] feat(transport) : onglet adresses transporteur (ERP-167) --- frontend/i18n/locales/fr.json | 20 +- .../components/CarrierAddressBlock.vue | 237 ++++++++++++++++++ .../__tests__/CarrierAddressBlock.spec.ts | 150 +++++++++++ .../__tests__/useCarrierForm.test.ts | 125 ++++++++- .../transport/composables/useCarrierForm.ts | 148 ++++++++++- .../modules/transport/pages/carriers/new.vue | 167 +++++++++++- .../modules/transport/types/carrierForm.ts | 29 +++ .../transport/utils/forms/carrierAddress.ts | 35 +++ 8 files changed, 902 insertions(+), 9 deletions(-) create mode 100644 frontend/modules/transport/components/CarrierAddressBlock.vue create mode 100644 frontend/modules/transport/components/__tests__/CarrierAddressBlock.spec.ts create mode 100644 frontend/modules/transport/utils/forms/carrierAddress.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 0523539..41d837e 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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" } } } diff --git a/frontend/modules/transport/components/CarrierAddressBlock.vue b/frontend/modules/transport/components/CarrierAddressBlock.vue new file mode 100644 index 0000000..3c099e4 --- /dev/null +++ b/frontend/modules/transport/components/CarrierAddressBlock.vue @@ -0,0 +1,237 @@ + + + diff --git a/frontend/modules/transport/components/__tests__/CarrierAddressBlock.spec.ts b/frontend/modules/transport/components/__tests__/CarrierAddressBlock.spec.ts new file mode 100644 index 0000000..e7c0889 --- /dev/null +++ b/frontend/modules/transport/components/__tests__/CarrierAddressBlock.spec.ts @@ -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 = {}) { + 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, 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) + }) +}) diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index d77bda9..ed9b4c0 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -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', () => ({ @@ -431,4 +432,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, 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) + }) }) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index ae6b331..093f4c9 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -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(null) const mainLocked = ref(false) const mainSubmitting = ref(false) + const tabSubmitting = ref(false) // ── Formulaire principal ────────────────────────────────────────────────── const main = reactive(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( + rows: T[], + target: Ref[]>, + saveRow: (row: T, index: number) => Promise, + onUnmappedError: (error: unknown, index: number) => void, + shouldSkip?: (row: T, index: number) => boolean, + ): Promise { + 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([emptyCarrierAddress()]) + // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. + const addressErrors = ref[]>([]) + + // « + 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 { + 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 { + 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), @@ -290,6 +417,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, + }] return true } @@ -321,6 +458,7 @@ export function useCarrierForm() { carrierId, mainLocked, mainSubmitting, + tabSubmitting, mainErrors, // affichage conditionnel isLiot, @@ -336,6 +474,13 @@ export function useCarrierForm() { validated, editMode, isValidated, + // adresses + addresses, + addressErrors, + canAddAddress, + addAddress, + removeAddress, + submitAddresses, // actions validateMainFront, buildMainPayload, @@ -343,5 +488,6 @@ export function useCarrierForm() { patchCarrier, applyQualimatSelection, completeTab, + submitRows, } } diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index bb62c09..848463c 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -170,7 +170,44 @@ - + + + + + + + + +

{{ t('transport.carriers.form.confirmDelete.message') }}

+ +
diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts index db87a65..f3d3596 100644 --- a/frontend/modules/transport/types/carrierForm.ts +++ b/frontend/modules/transport/types/carrierForm.ts @@ -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. diff --git a/frontend/modules/transport/utils/forms/carrierAddress.ts b/frontend/modules/transport/utils/forms/carrierAddress.ts new file mode 100644 index 0000000..0fb147e --- /dev/null +++ b/frontend/modules/transport/utils/forms/carrierAddress.ts @@ -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 { + return { + country: address.country, + postalCode: address.postalCode || null, + city: address.city || null, + street: address.street || null, + streetComplement: address.streetComplement || null, + } +}