feat(transport) : adresse unique par transporteur (OneToOne back + un seul bloc front) (ERP-172)
This commit is contained in:
@@ -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<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'
|
||||
}
|
||||
/** Remplit l'unique bloc adresse (CP + ville + rue). */
|
||||
function fillAddress(form: ReturnType<typeof useCarrierForm>): 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')
|
||||
})
|
||||
|
||||
@@ -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<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,
|
||||
})
|
||||
}
|
||||
// ── 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<CarrierAddressFormDraft>(emptyCarrierAddress())
|
||||
// Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101).
|
||||
const addressErrors = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
async function submitAddress(onError: (error: unknown) => void): Promise<boolean> {
|
||||
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,
|
||||
|
||||
@@ -124,19 +124,16 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
:removable="false"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div 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.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,12 +239,9 @@ const {
|
||||
certificationReadonly,
|
||||
showCharteredFields,
|
||||
showDischarge,
|
||||
addresses,
|
||||
address,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
submitAddress,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
@@ -368,7 +362,7 @@ async function onUpdateMain(): Promise<void> {
|
||||
}
|
||||
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
||||
}
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
@@ -383,10 +377,6 @@ async function onSubmitPrices(): Promise<void> {
|
||||
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
|
||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
deleteConfirm.action = () => { void removeAddress(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
function askRemoveContact(index: number): void {
|
||||
deleteConfirm.action = () => { void removeContact(index) }
|
||||
deleteConfirm.open = true
|
||||
|
||||
@@ -116,9 +116,8 @@
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172). -->
|
||||
<CarrierAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:country-options="countryOptionsFor(address.country)"
|
||||
readonly
|
||||
@@ -311,11 +310,10 @@ const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Au moins un bloc affiché même sans donnée (bloc vide en lecture seule).
|
||||
const addresses = computed(() => {
|
||||
const list = (carrier.value?.addresses ?? []).map(mapAddressToDraft)
|
||||
return list.length > 0 ? list : [mapAddressToDraft({ id: 0, '@id': '' })]
|
||||
})
|
||||
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
|
||||
const address = computed(() => carrier.value?.address
|
||||
? mapAddressToDraft(carrier.value.address)
|
||||
: mapAddressToDraft({ id: 0, '@id': '' }))
|
||||
const contacts = computed(() => {
|
||||
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
|
||||
|
||||
@@ -202,29 +202,19 @@
|
||||
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:removable="false"
|
||||
:readonly="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@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')"
|
||||
@@ -412,12 +402,9 @@ const {
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
isValidated,
|
||||
addresses,
|
||||
address,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
submitAddress,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
@@ -609,7 +596,7 @@ function apiErrorMessage(error: unknown): string {
|
||||
|
||||
/** 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({
|
||||
const ok = await submitAddress(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
@@ -618,14 +605,9 @@ async function onSubmitAddresses(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Modal de confirmation de suppression (générique : bloc adresse OU contact).
|
||||
// Modal de confirmation de suppression (générique : bloc contact OU prix).
|
||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
deleteConfirm.action = () => { void removeAddress(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
|
||||
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(error => toast.error({
|
||||
@@ -764,7 +746,7 @@ function goBack(): void {
|
||||
async function onSubmitMain(): Promise<void> {
|
||||
const ok = await submitMain()
|
||||
if (ok && isQualimat.value) {
|
||||
await submitAddresses(error => toast.error({
|
||||
await submitAddress(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
|
||||
@@ -79,7 +79,8 @@ export interface CarrierDetail extends HydraRef {
|
||||
dischargeDocument?: Relation
|
||||
qualimatCarrier?: Relation
|
||||
isArchived?: boolean
|
||||
addresses?: CarrierAddressRead[]
|
||||
// Adresse UNIQUE (OneToOne, ERP-172) : objet embarqué (ou absent), pas une liste.
|
||||
address?: CarrierAddressRead | null
|
||||
contacts?: CarrierContactRead[]
|
||||
prices?: CarrierPriceRead[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-172 — adresse UNIQUE par transporteur (decision metier : un transporteur a
|
||||
* au plus une adresse). Bascule la relation carrier_address.carrier_id en OneToOne :
|
||||
* remplace l'index simple idx_carrier_address_carrier par une contrainte d'unicite
|
||||
* uniq_carrier_address_carrier. La garde applicative (CarrierAddressProcessor) renvoie
|
||||
* un 409 explicite avant d'atteindre cette contrainte.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260617140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-172 : adresse unique par transporteur — index unique sur carrier_address.carrier_id (OneToOne).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX idx_carrier_address_carrier');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_carrier_address_carrier');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
}
|
||||
}
|
||||
@@ -198,10 +198,13 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $liotPlates = null;
|
||||
|
||||
// === Adresse UNIQUE (OneToOne) — EMBARQUEE dans le DETAIL (read-group sur le getter) ===
|
||||
// Metier : un transporteur a au plus UNE adresse (decision metier ERP-172). La
|
||||
// FK porte un index UNIQUE (cote CarrierAddress.carrier en OneToOne owning side).
|
||||
#[ORM\OneToOne(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private ?CarrierAddress $address = null;
|
||||
|
||||
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
||||
/** @var Collection<int, CarrierAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, CarrierContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
@@ -228,9 +231,8 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,32 +411,22 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierAddress> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
public function getAddress(): ?CarrierAddress
|
||||
{
|
||||
return $this->addresses;
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function addAddress(CarrierAddress $address): static
|
||||
public function setAddress(?CarrierAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$this->address = $address;
|
||||
if (null !== $address && $address->getCarrier() !== $this) {
|
||||
$address->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(CarrierAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
|
||||
$address->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierContact> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
|
||||
@@ -58,14 +58,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/addresses',
|
||||
uriTemplate: '/carriers/{carrierId}/address',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
|
||||
// read:false : pas de stade lecture du parent. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent),
|
||||
// qui refuse aussi une 2e adresse (RG metier : adresse UNIQUE — 409).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
@@ -86,7 +85,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
// Adresse UNIQUE par transporteur (OneToOne owning side) : contrainte d'unicite
|
||||
// sur carrier_id (decision metier ERP-172).
|
||||
#[ORM\UniqueConstraint(name: 'uniq_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
@@ -100,7 +101,7 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
|
||||
#[ORM\OneToOne(targetEntity: Carrier::class, inversedBy: 'address')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
|
||||
+27
-1
@@ -6,12 +6,14 @@ namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
@@ -63,6 +65,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardSingleAddress($data, $operation);
|
||||
$this->guardCharteredAddress($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
@@ -70,7 +73,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
||||
* (/carriers/{carrierId}/address) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
||||
@@ -98,6 +101,29 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
$address->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse UNIQUE par transporteur (decision metier ERP-172) : un POST sur un
|
||||
* transporteur qui a deja une adresse -> 409 explicite (plutot qu'un 500 sur la
|
||||
* contrainte d'unicite carrier_id). No-op sur PATCH (mise a jour de l'adresse
|
||||
* existante). Lookup repository pour rester robuste a la synchro bidirectionnelle.
|
||||
*/
|
||||
private function guardSingleAddress(CarrierAddress $address, Operation $operation): void
|
||||
{
|
||||
if (!$operation instanceof Post) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $address->getCarrier();
|
||||
if (!$carrier instanceof Carrier) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(CarrierAddress::class)->findOneBy(['carrier' => $carrier]);
|
||||
if (null !== $existing && $existing->getId() !== $address->getId()) {
|
||||
throw new ConflictHttpException('Ce transporteur a déjà une adresse.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||
|
||||
@@ -189,7 +189,8 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
$address->setStreet($street);
|
||||
$carrier->addAddress($address);
|
||||
// Adresse UNIQUE (OneToOne) — ERP-172.
|
||||
$carrier->setAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,7 +149,7 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$carrier->setAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
||||
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
* POST /api/carriers/{id}/address, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||
@@ -55,7 +55,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||
]);
|
||||
@@ -73,7 +73,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86000', // Poitiers
|
||||
@@ -91,7 +91,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000'],
|
||||
]);
|
||||
@@ -107,7 +107,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'country' => 'France',
|
||||
@@ -119,13 +119,30 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testSecondAddressReturns409(): void
|
||||
{
|
||||
// Adresse UNIQUE (ERP-172) : un 2e POST sur un transporteur qui a deja une
|
||||
// adresse -> 409 explicite (garde CarrierAddressProcessor avant la contrainte
|
||||
// d'unicite carrier_id).
|
||||
$address = $this->seedAddress('Deja Une Adresse', false);
|
||||
$carrier = $address->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '17000', 'city' => 'La Rochelle', 'street' => '2 rue Neuve'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testPostAddressOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
|
||||
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/addresses', [
|
||||
$client->request('POST', '/api/carriers/999999/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
@@ -156,7 +173,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
@@ -201,7 +218,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$carrier->setAddress($address);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -93,8 +93,9 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
self::assertArrayHasKey('isChartered', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
// Adresse UNIQUE (OneToOne, ERP-172) : embarquee en OBJET (pas une liste).
|
||||
self::assertIsArray($data['address']);
|
||||
self::assertSame('Poitiers', $data['address']['city']);
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
@@ -209,7 +210,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('qualimatCarrier', $detail);
|
||||
self::assertArrayHasKey('addresses', $detail);
|
||||
self::assertArrayHasKey('address', $detail);
|
||||
self::assertArrayHasKey('contacts', $detail);
|
||||
self::assertArrayHasKey('prices', $detail);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user