From e76bd1dd630c2a88927ccc80f73e832d2cc20a39 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 17:32:29 +0200 Subject: [PATCH] feat(transport) : adresse unique par transporteur (OneToOne back + un seul bloc front) (ERP-172) --- .../__tests__/useCarrierForm.test.ts | 77 +++-------- .../transport/composables/useCarrierForm.ts | 130 ++++++++---------- .../transport/pages/carriers/[id]/edit.vue | 24 +--- .../transport/pages/carriers/[id]/index.vue | 12 +- .../modules/transport/pages/carriers/new.vue | 36 ++--- .../transport/utils/forms/carrierMappers.ts | 3 +- migrations/Version20260617140000.php | 40 ++++++ .../Transport/Domain/Entity/Carrier.php | 34 ++--- .../Domain/Entity/CarrierAddress.php | 15 +- .../Processor/CarrierAddressProcessor.php | 28 +++- .../DataFixtures/CarrierFixtures.php | 3 +- .../Api/AbstractCarrierApiTestCase.php | 2 +- .../Transport/Api/CarrierAddressApiTest.php | 33 +++-- .../Api/CarrierSerializationContractTest.php | 7 +- 14 files changed, 219 insertions(+), 225 deletions(-) create mode 100644 migrations/Version20260617140000.php diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index f695eff..506b4ee 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -486,14 +486,13 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { }) }) - it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => { + it('applyQualimatSelection pré-remplit l\'adresse unique à la création (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({ + expect(form.address.value).toEqual({ id: null, country: 'France', postalCode: '86000', @@ -504,53 +503,38 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { }) }) -describe('useCarrierForm — onglet Adresses (ERP-167)', () => { +describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)', () => { beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() mockDelete.mockReset() }) - /** Transporteur créé, onglet Adresses accessible. */ + /** Transporteur créé, onglet Adresse 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' - } + /** Remplit l'unique bloc adresse (CP + ville + rue). */ + function fillAddress(form: ReturnType): void { + const a = form.address.value + 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 () => { + it('submitAddress : POST sur /carriers/{id}/address, capture l\'id, finalise l\'onglet', async () => { mockPost.mockResolvedValueOnce({ id: 88 }) const form = createdForm() fillAddress(form) - const ok = await form.submitAddresses(vi.fn()) + const ok = await form.submitAddress(vi.fn()) expect(ok).toBe(true) const [url, body, opts] = mockPost.mock.calls[0] ?? [] - expect(url).toBe('/carriers/7/addresses') + expect(url).toBe('/carriers/7/address') expect(body).toEqual({ country: 'France', postalCode: '86100', @@ -559,24 +543,23 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => { streetComplement: null, }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) - expect(form.addresses.value[0]?.id).toBe(88) + expect(form.address.value.id).toBe(88) expect(form.isValidated('addresses')).toBe(true) }) - it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => { + it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillAddress(form) - const first = form.addresses.value[0] - if (first) first.id = 88 + form.address.value.id = 88 - await form.submitAddresses(vi.fn()) + await form.submitAddress(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 () => { + it('submitAddress : mappe les 422 inline par champ et ne finalise pas l\'onglet (RG-4.05)', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, @@ -586,27 +569,12 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => { const form = createdForm() fillAddress(form) - const ok = await form.submitAddresses(vi.fn()) + const ok = await form.submitAddress(vi.fn()) expect(ok).toBe(false) - expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.') + expect(form.addressErrors.value.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) - }) }) describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => { @@ -976,7 +944,7 @@ describe('useCarrierForm — édition (ERP-170)', () => { id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS', - addresses: [{ '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }], + address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }, contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }], prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }], }) @@ -985,8 +953,7 @@ describe('useCarrierForm — édition (ERP-170)', () => { expect(form.editMode.value).toBe(true) expect(form.main.name).toBe('TRANSPORTS ACME') expect(form.main.certificationType).toBe('GMP_PLUS') - expect(form.addresses.value).toHaveLength(1) - expect(form.addresses.value[0]?.id).toBe(3) + expect(form.address.value.id).toBe(3) expect(form.contacts.value[0]?.id).toBe(9) expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3') }) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index e5e1923..007ece8 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -16,7 +16,7 @@ import { type CarrierMainResponse, type CarrierPriceFormDraft, } from '~/modules/transport/types/carrierForm' -import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress' +import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress' import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact' import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice' import { @@ -369,8 +369,8 @@ export function useCarrierForm() { Object.assign(main, mapMainToDraft(detail)) - const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft) - addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()] + // Adresse UNIQUE (ERP-172) : objet `address` (ou null) au lieu d'une liste. + address.value = detail.address ? mapAddressToDraft(detail.address) : emptyCarrierAddress() const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft) contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()] @@ -435,75 +435,52 @@ export function useCarrierForm() { 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, - }) - } + // ── Onglet Adresse (ERP-167 / ERP-172 : adresse UNIQUE) ─────────────────── + // Un transporteur a au plus UNE adresse (décision métier ERP-172) : un seul + // bloc, pas d'ajout/suppression. `id` null tant que l'adresse n'est pas créée. + const address = ref(emptyCarrierAddress()) + // Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101). + const addressErrors = ref>({}) /** - * 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é). + * Valide l'onglet Adresse : POST sur /carriers/{id}/address (création) ou PATCH + * sur /carrier_addresses/{id} (mise à jour), groupe carrier:write:addresses. + * Erreurs 422 mappées inline par champ (RG-4.05 « obligatoire si affrété » + * re-validée back). Retourne true si l'onglet a été validé. */ - async function submitAddresses(onError: (error: unknown) => void): Promise { + async function submitAddress(onError: (error: unknown) => void): Promise { if (carrierId.value === null || tabSubmitting.value) { return false } tabSubmitting.value = true + addressErrors.value = {} 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 + const body = buildCarrierAddressPayload(address.value) + if (address.value.id === null) { + const created = await api.post<{ id: number }>( + `/carriers/${carrierId.value}/address`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + address.value.id = created.id + } + else { + await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false }) } completeTab('addresses') return true } + 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) { + addressErrors.value = mapped + } + else { + onError(error) + } + return false + } finally { tabSubmitting.value = false } @@ -741,16 +718,20 @@ 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, - }] + // RG-4.05 : à la CRÉATION, pré-remplit l'adresse (unique) par copie du + // référentiel QUALIMAT (champs éditables, la FK QUALIMAT survit — § 2.5). + // En MODIFICATION (ERP-172) : on NE TOUCHE PAS l'adresse déjà saisie — la + // re-sélection Qualimat actualise seulement nom + certification + FK. + if (!editMode.value) { + address.value = { + id: null, + country: 'France', + postalCode: row.postalCode || null, + city: row.city || null, + street: row.address || null, + streetComplement: null, + } + } return true } @@ -799,13 +780,10 @@ export function useCarrierForm() { validated, editMode, isValidated, - // adresses - addresses, + // adresse (unique) + address, addressErrors, - canAddAddress, - addAddress, - removeAddress, - submitAddresses, + submitAddress, // contacts contacts, contactErrors, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 77c3f13..4ef6328 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -124,19 +124,16 @@