feat(transport) : consultation + modification transporteur (ERP-170)
This commit is contained in:
@@ -912,3 +912,49 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => {
|
||||
expect(form.prices.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — édition (ERP-170)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('prefillFrom : peuple carrierId + principal + sous-collections, passe en editMode', () => {
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({
|
||||
'@id': '/api/carriers/7',
|
||||
id: 7,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'GMP_PLUS',
|
||||
addresses: [{ '@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' }],
|
||||
})
|
||||
|
||||
expect(form.carrierId.value).toBe(7)
|
||||
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.contacts.value[0]?.id).toBe(9)
|
||||
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
|
||||
})
|
||||
|
||||
it('updateMain : PATCH /carriers/{id} (pas de POST), réaffiche le nom normalisé', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({ '@id': '/api/carriers/7', id: 7, name: 'Transports Acme', certificationType: 'GMP_PLUS' })
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/carriers/7',
|
||||
expect.objectContaining({ name: 'Transports Acme', certificationType: 'GMP_PLUS' }),
|
||||
{ toast: false },
|
||||
)
|
||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ref } from 'vue'
|
||||
import type { CarrierDetail } from '~/modules/transport/utils/forms/carrierMappers'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un transporteur unique (écrans Consultation /
|
||||
* Modification, ERP-170). Miroir de `useProvider` (M3) / `useSupplier` (M2). Lit le
|
||||
* détail embarqué via `GET /api/carriers/{id}` (qualimatCarrier + addresses /
|
||||
* contacts / prices sous `carrier:item:read`, relations cross-module via leurs
|
||||
* read-groups) — une SEULE requête peuple les deux écrans (embed borné, pas de N+1).
|
||||
*
|
||||
* L'en-tête `Accept: application/ld+json` est imposé pour obtenir le payload Hydra
|
||||
* complet (avec les `@id` des relations embarquées, indispensables au préremplissage).
|
||||
*
|
||||
* État 100 % local à l'instance (refs). Les erreurs d'archivage / restauration
|
||||
* (notamment le 409 d'homonyme actif à la restauration) sont PROPAGÉES à l'appelant.
|
||||
*/
|
||||
export function useCarrier(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const carrier = ref<CarrierDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */
|
||||
function fetchDetail(): Promise<CarrierDetail> {
|
||||
return api.get<CarrierDetail>(
|
||||
`/carriers/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le détail du transporteur. En cas d'échec : `error = true`, `carrier = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
carrier.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
carrier.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe carrier:write:archive ;
|
||||
* tout autre champ → 422, security archive = Admin seul), puis RECHARGE le détail
|
||||
* complet (la réponse du PATCH ne porte pas l'embed des sous-collections). Toute
|
||||
* erreur est propagée à l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/carriers/${id}`, { isArchived }, { toast: false })
|
||||
carrier.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
carrier,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
import { buildCarrierAddressPayload, isCarrierAddressValid } 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 {
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapMainToDraft,
|
||||
mapPriceToDraft,
|
||||
type CarrierDetail,
|
||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
|
||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
||||
@@ -257,6 +264,68 @@ export function useCarrierForm() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MODIFICATION du formulaire principal (ERP-170) : PATCH /api/carriers/{id} sur le
|
||||
* groupe carrier:write:main (PAS de re-POST). Pré-check front + 409 doublon / 422
|
||||
* inline comme `submitMain`. Ne verrouille rien et ne bascule pas d'onglet (édition
|
||||
* = navigation libre). Retourne true si le PATCH a réussi.
|
||||
*/
|
||||
async function updateMain(): Promise<boolean> {
|
||||
if (carrierId.value === null || mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const updated = await api.patch<CarrierMainResponse>(
|
||||
`/carriers/${carrierId.value}`,
|
||||
buildMainPayload(),
|
||||
{ toast: false },
|
||||
)
|
||||
main.name = updated.name ?? main.name
|
||||
main.certificationType = updated.certificationType ?? main.certificationType
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('transport.carriers.form.duplicateName')
|
||||
mainErrors.setError('name', message)
|
||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-remplit le formulaire depuis le détail `GET /api/carriers/{id}` (écran
|
||||
* Modification) : peuple carrierId + principal + adresses / contacts / prix via les
|
||||
* mappers, passe en `editMode` (navigation libre, tous onglets accessibles, bloc
|
||||
* principal éditable). Au moins un bloc Adresse / Contact affiché même sans donnée.
|
||||
*/
|
||||
function prefillFrom(detail: CarrierDetail): void {
|
||||
carrierId.value = detail.id
|
||||
editMode.value = true
|
||||
mainLocked.value = false
|
||||
unlockedIndex.value = tabKeys.value.length - 1
|
||||
|
||||
Object.assign(main, mapMainToDraft(detail))
|
||||
|
||||
const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft)
|
||||
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()]
|
||||
|
||||
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
|
||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
|
||||
|
||||
prices.value = (detail.prices ?? []).map(mapPriceToDraft)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
||||
@@ -702,6 +771,8 @@ export function useCarrierForm() {
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
updateMain,
|
||||
prefillFrom,
|
||||
patchCarrier,
|
||||
applyQualimatSelection,
|
||||
completeTab,
|
||||
|
||||
Reference in New Issue
Block a user