From f70e701854bae2008dc7e57169d274e603b9df06 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 17:22:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport)=20:=20saisie=20assist=C3=A9e=20?= =?UTF-8?q?QUALIMAT=20+=20champs=20conditionnels=20(ERP-166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 31 +- .../__tests__/useCarrierForm.test.ts | 150 +++++++++ .../__tests__/useQualimatSearch.test.ts | 55 ++++ .../transport/composables/useCarrierForm.ts | 108 +++++- .../composables/useQualimatSearch.ts | 76 +++++ .../modules/transport/pages/carriers/new.vue | 311 ++++++++++++++++-- .../modules/transport/types/carrierForm.ts | 55 +++- 7 files changed, 738 insertions(+), 48 deletions(-) create mode 100644 frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts create mode 100644 frontend/modules/transport/composables/useQualimatSearch.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 3bbee0f..d0acd43 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -526,7 +526,12 @@ "toast": { "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du répertoire transporteurs a échoué. Réessayez.", - "createSuccess": "Transporteur créé avec succès" + "createSuccess": "Transporteur créé avec succès", + "integrateSuccess": "Transporteur QUALIMAT intégré" + }, + "containerType": { + "BENNE": "Benne", + "FOND_MOUVANT": "Fond mouvant" }, "tab": { "qualimat": "Qualimat", @@ -543,7 +548,29 @@ "main": { "name": "Nom", "certificationType": "Certification transport", - "isChartered": "Affréter" + "isChartered": "Affréter", + "indexationRate": "Indexation %", + "containerType": "Benne / Fond mouvant", + "volumeM3": "Volume m³", + "discharge": "Décharge", + "liotPlates": "Immatriculations LIOT", + "liotPlatesHint": "Séparées par « ; »" + }, + "qualimat": { + "search": "Rechercher un transporteur QUALIMAT", + "empty": "Aucun transporteur QUALIMAT trouvé.", + "continue": "Continuer", + "columns": { + "name": "Nom", + "address": "Adresse", + "validityDate": "Date de validité" + }, + "confirm": { + "title": "Intégration QUALIMAT", + "message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?", + "cancel": "Annuler", + "confirm": "Intégrer" + } }, "errors": { "nameRequired": "Le nom du transporteur est obligatoire." diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index 729c1cb..c49816d 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -189,3 +189,153 @@ describe('useCarrierForm', () => { expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false }) }) }) + +describe('useCarrierForm — champs conditionnels (ERP-166)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('cas LIOT (insensible à la casse) : masque la certification, payload réduit', () => { + const form = useCarrierForm() + form.main.name = 'liot' + + expect(form.isLiot.value).toBe(true) + expect(form.showCertification.value).toBe(false) + + form.main.liotPlates = 'AA-123-BB ; CC-456-DD' + expect(form.buildMainPayload()).toEqual({ + name: 'liot', + isChartered: false, + liotPlates: 'AA-123-BB ; CC-456-DD', + }) + }) + + it('LIOT masque les champs conditionnels (affrètement / décharge)', () => { + const form = useCarrierForm() + form.main.name = 'LIOT' + form.main.isChartered = true + form.main.certificationType = 'AUTRE' + + expect(form.showCharteredFields.value).toBe(false) + expect(form.showDischarge.value).toBe(false) + }) + + it('RG-4.03 affrété : indexation / contenant / volume visibles et dans le payload', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + expect(form.showCharteredFields.value).toBe(true) + + form.main.indexationRate = '5' + form.main.containerType = 'BENNE' + form.main.volumeM3 = '30' + + expect(form.buildMainPayload()).toEqual({ + name: 'Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + indexationRate: '5', + containerType: 'BENNE', + volumeM3: '30', + }) + }) + + it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + expect(form.buildMainPayload()).toEqual({ + name: 'Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + }) + }) + + it('RG-4.02 AUTRE : décharge visible + dischargeDocument dans le payload si IRI résolu', () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'AUTRE' + + expect(form.showDischarge.value).toBe(true) + + form.main.dischargeDocumentIri = '/api/uploaded_documents/7' + expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' }) + }) +}) + +describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { + const QUALIMAT_ROW = { + '@id': '/api/qualimat_carriers/42', + id: '42', + name: 'TRANSPORTS QUALIMAT', + siret: '12345678900012', + address: '1 rue du Port', + postalCode: '86000', + city: 'Poitiers', + validityDate: '2027-01-15', + status: 'VALIDE', + } + + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('copie name + certificationType=QUALIMAT (readonly) + IRI + adresse, sans PATCH avant création', async () => { + const form = useCarrierForm() + + const ok = await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(ok).toBe(true) + expect(mockPatch).not.toHaveBeenCalled() + expect(form.main.name).toBe('TRANSPORTS QUALIMAT') + expect(form.main.certificationType).toBe('QUALIMAT') + expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42') + expect(form.isQualimat.value).toBe(true) + expect(form.certificationReadonly.value).toBe(true) + expect(form.qualimatAddress.value).toEqual({ + country: 'France', + postalCode: '86000', + city: 'Poitiers', + street: '1 rue du Port', + }) + }) + + it('après création : PATCH /carriers/{id} avec qualimatCarrier + name + certification', async () => { + mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' }) + mockPatch.mockResolvedValueOnce({}) + const form = useCarrierForm() + form.main.name = 'X' + form.main.certificationType = 'GMP_PLUS' + await form.submitMain() + + const ok = await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(ok).toBe(true) + expect(mockPatch).toHaveBeenCalledWith( + '/carriers/9', + { + qualimatCarrier: '/api/qualimat_carriers/42', + name: 'TRANSPORTS QUALIMAT', + certificationType: 'QUALIMAT', + }, + { toast: false }, + ) + }) + + it('buildMainPayload inclut qualimatCarrier + certificationType QUALIMAT après intégration', async () => { + const form = useCarrierForm() + form.main.name = 'Acme' + await form.applyQualimatSelection(QUALIMAT_ROW) + + expect(form.buildMainPayload()).toMatchObject({ + qualimatCarrier: '/api/qualimat_carriers/42', + certificationType: 'QUALIMAT', + }) + }) +}) diff --git a/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts b/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts new file mode 100644 index 0000000..339f8d3 --- /dev/null +++ b/frontend/modules/transport/composables/__tests__/useQualimatSearch.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01). + * + * `useQualimatSearch` interroge `GET /api/qualimat_carriers?search=`. On vérifie le + * CONTRAT (pas le timing du debounce, couvert par `debounce.test.ts`) via `fetchNow` : + * - ressource ciblée + paramètre `search` (trimé) + header `Accept: application/ld+json` ; + * - consommation de l'enveloppe Hydra (`member`) ; + * - échec réseau → résultats vidés, pas de throw (recherche non bloquante). + */ + +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +const { useQualimatSearch } = await import('../useQualimatSearch') + +describe('useQualimatSearch', () => { + beforeEach(() => { + mockGet.mockReset() + }) + + it('fetchNow cible /qualimat_carriers (search trimé, ld+json) et consomme member', async () => { + mockGet.mockResolvedValueOnce({ + member: [{ '@id': '/api/qualimat_carriers/1', id: '1', name: 'ACME', validityDate: '2027-01-01' }], + }) + const q = useQualimatSearch() + + await q.fetchNow(' acme ') + + expect(mockGet).toHaveBeenCalledWith( + '/qualimat_carriers', + { search: 'acme' }, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + expect(q.results.value).toHaveLength(1) + expect(q.results.value[0]?.name).toBe('ACME') + expect(q.loading.value).toBe(false) + }) + + it('échec réseau : résultats vidés, pas de throw', async () => { + mockGet.mockRejectedValueOnce(new Error('network')) + const q = useQualimatSearch() + + await expect(q.fetchNow('x')).resolves.toBeUndefined() + expect(q.results.value).toEqual([]) + expect(q.loading.value).toBe(false) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index ef6326e..dba641f 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -1,10 +1,16 @@ -import { reactive, ref } from 'vue' +import { computed, reactive, ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { + emptyCarrierAddressCopy, emptyCarrierMain, + type CarrierAddressCopy, type CarrierMainDraft, type CarrierMainResponse, } from '~/modules/transport/types/carrierForm' +import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' + +/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */ +const LIOT_NAME = 'LIOT' /** * Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) — @@ -50,6 +56,24 @@ export function useCarrierForm() { // ── Formulaire principal ────────────────────────────────────────────────── const main = reactive(emptyCarrierMain()) + // Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses, + // ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré. + const qualimatAddress = ref(emptyCarrierAddressCopy()) + + // ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ── + // Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué. + const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME) + // Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ». + const isQualimat = computed(() => main.qualimatCarrierIri !== null) + // Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé). + const showCertification = computed(() => !isLiot.value) + const certificationReadonly = computed(() => isQualimat.value || mainLocked.value) + // RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et + // obligatoires si « Affréter » coché — masqués en cas LIOT. + const showCharteredFields = computed(() => main.isChartered && !isLiot.value) + // RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT). + const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value) + // ── Onglets : ordre + gating progressif ─────────────────────────────────── const tabKeys = ref([...CARRIER_TAB_KEYS]) // Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé). @@ -92,15 +116,45 @@ export function useCarrierForm() { * certification) sur le champ plutôt qu'une erreur de type. */ function buildMainPayload(): Record { - const payload: Record = { - isChartered: main.isChartered, + // Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs + // sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit). + if (isLiot.value) { + const payload: Record = { name: main.name, isChartered: false } + if (main.liotPlates.trim()) { + payload.liotPlates = main.liotPlates + } + return payload } - if (main.name?.trim()) { + + const payload: Record = { isChartered: main.isChartered } + if (main.name.trim()) { payload.name = main.name } if (main.certificationType) { payload.certificationType = main.certificationType } + // FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée. + if (main.qualimatCarrierIri) { + payload.qualimatCarrier = main.qualimatCarrierIri + } + // RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand + // absente pour que la 422 « obligatoire » porte sur le champ. + if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) { + payload.dischargeDocument = main.dischargeDocumentIri + } + // RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ; + // omis quand vides pour déclencher la 422 NotBlank inline sur le champ. + if (main.isChartered) { + if (main.indexationRate.trim()) { + payload.indexationRate = main.indexationRate + } + if (main.containerType) { + payload.containerType = main.containerType + } + if (main.volumeM3.trim()) { + payload.volumeM3 = main.volumeM3 + } + } return payload } @@ -162,6 +216,43 @@ export function useCarrierForm() { await api.patch(`/carriers/${carrierId.value}`, payload, { toast: 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), + * pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses). + * Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste + * la copie via un PATCH partiel `carrier:write:main`. La copie locale a lieu + * dans tous les cas. Retourne true si l'intégration a abouti. + */ + async function applyQualimatSelection(row: QualimatCarrierRow): Promise { + main.name = row.name ?? '' + main.certificationType = 'QUALIMAT' + main.qualimatCarrierIri = row['@id'] + qualimatAddress.value = { + country: 'France', + postalCode: row.postalCode ?? '', + city: row.city ?? '', + street: row.address ?? '', + } + + if (carrierId.value === null) { + return true + } + + try { + await patchCarrier({ + qualimatCarrier: row['@id'], + name: row.name, + certificationType: 'QUALIMAT', + }) + return true + } + catch (error) { + mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') }) + return false + } + } + /** * Marque un onglet validé (passe en lecture seule), déverrouille et avance à * l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création @@ -186,10 +277,18 @@ export function useCarrierForm() { return { // état main, + qualimatAddress, carrierId, mainLocked, mainSubmitting, mainErrors, + // affichage conditionnel + isLiot, + isQualimat, + showCertification, + certificationReadonly, + showCharteredFields, + showDischarge, // onglets tabKeys, activeTab, @@ -202,6 +301,7 @@ export function useCarrierForm() { buildMainPayload, submitMain, patchCarrier, + applyQualimatSelection, completeTab, } } diff --git a/frontend/modules/transport/composables/useQualimatSearch.ts b/frontend/modules/transport/composables/useQualimatSearch.ts new file mode 100644 index 0000000..b14ad34 --- /dev/null +++ b/frontend/modules/transport/composables/useQualimatSearch.ts @@ -0,0 +1,76 @@ +import { ref } from 'vue' +import { debounce } from '~/shared/utils/debounce' +import type { HydraCollection } from '~/shared/utils/api' + +/** + * Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe + * `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier` + * (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de + * validité » (RG-4.04). + */ +export interface QualimatCarrierRow { + '@id': string + id: string + name: string | null + siret: string | null + address: string | null + postalCode: string | null + city: string | null + validityDate: string | null + status: string | null +} + +/** Délai de debounce de la recherche (ms) — une requête après la dernière frappe. */ +const SEARCH_DEBOUNCE_MS = 300 + +/** + * Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7). + * + * `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes + * actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Alimente + * le tableau de sélection de l'onglet Qualimat ; la ligne choisie est copiée dans + * le formulaire principal (cf. `useCarrierForm.applyQualimatSelection`). + * + * Volontairement PAR INSTANCE (état local à l'écran d'ajout). `search()` est + * debouncé (anti-spam réseau) ; `fetchNow()` expose l'appel immédiat (montage / + * tests). + */ +export function useQualimatSearch() { + const api = useApi() + + const results = ref([]) + const loading = ref(false) + + /** Lance immédiatement la recherche (sans debounce). */ + async function fetchNow(term: string): Promise { + loading.value = true + try { + const data = await api.get>( + '/qualimat_carriers', + { search: term.trim() }, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + results.value = data.member ?? [] + } + catch { + // Échec réseau / 403 : on vide les résultats, pas de toast (la recherche + // assistée est non bloquante — l'utilisateur peut saisir manuellement). + results.value = [] + } + finally { + loading.value = false + } + } + + // Recherche debouncée branchée sur le champ de recherche de l'onglet Qualimat. + const search = debounce((term: string) => { + void fetchNow(term) + }, SEARCH_DEBOUNCE_MS) + + return { + results, + loading, + search, + fetchNow, + } +} diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index c045581..86628a4 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -13,11 +13,10 @@ + Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) → seul + « immatriculations » ; certification AUTRE → champ Decharge ; Affreter + coche → indexation / contenant / volume. La certification est en lecture + seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
- + - -
- +
@@ -61,13 +135,76 @@
+ Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie + assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux + tickets suivants (placeholders « A venir »). --> + + + + + + + + +

{{ t('transport.carriers.form.qualimat.confirm.message') }}

+ +