From 07e0bcbccee13864850e333c42dcb4b3f5516578 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 10:05:27 +0200 Subject: [PATCH] feat(transport) : onglet prix transporteur (ERP-169) --- frontend/i18n/locales/fr.json | 53 ++- .../components/CarrierPriceBlock.vue | 312 ++++++++++++++++++ .../__tests__/useCarrierForm.test.ts | 172 +++++++++- .../transport/composables/useCarrierForm.ts | 85 +++++ .../modules/transport/pages/carriers/new.vue | 97 +++++- .../modules/transport/types/carrierForm.ts | 42 +++ .../transport/utils/forms/carrierPrice.ts | 77 +++++ 7 files changed, 819 insertions(+), 19 deletions(-) create mode 100644 frontend/modules/transport/components/CarrierPriceBlock.vue create mode 100644 frontend/modules/transport/utils/forms/carrierPrice.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d3d5b70..0022d6c 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -529,7 +529,8 @@ "createSuccess": "Transporteur créé avec succès", "integrateSuccess": "Transporteur QUALIMAT intégré", "addressSaved": "Adresse enregistrée", - "contactSaved": "Contact enregistré" + "contactSaved": "Contact enregistré", + "priceSaved": "Prix enregistré" }, "containerType": { "BENNE": "Benne", @@ -608,6 +609,28 @@ "message": "Cette suppression est définitive. Confirmer ?", "cancel": "Annuler", "confirm": "Supprimer" + }, + "price": { + "direction": "Sens", + "directionClient": "Client", + "directionSupplier": "Fournisseur", + "client": "Client", + "clientDeliveryAddress": "Adresse de livraison", + "departureSite": "Adresse de départ", + "supplier": "Fournisseur", + "supplierSupplyAddress": "Adresse d'approvisionnement", + "deliverySite": "Adresse de livraison", + "containerType": "Benne / Fond mouvant", + "pricingUnit": "Forfait / Tonne", + "pricingForfait": "Forfait", + "pricingTonne": "Tonne", + "price": "Prix", + "priceState": "État du prix", + "stateEnCours": "En cours", + "stateValide": "Validé", + "stateNonValide": "Non validé", + "add": "Nouveau prix", + "remove": "Supprimer le prix" } } } @@ -654,27 +677,27 @@ "delete": "Suppression" }, "entity": { - "core_user": "Utilisateur", - "core_role": "Rôle", - "core_permission": "Permission", - "sites_site": "Site", - "catalog_category": "Catégorie", - "commercial_client": "Client", + "core_user": "Utilisateur", + "core_role": "Rôle", + "core_permission": "Permission", + "sites_site": "Site", + "catalog_category": "Catégorie", + "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", - "commercial_clientrib": "RIB client", - "commercial_supplier": "Fournisseur", + "commercial_clientrib": "RIB client", + "commercial_supplier": "Fournisseur", "commercial_supplieraddress": "Adresse fournisseur", "commercial_suppliercontact": "Contact fournisseur", "commercial_supplierrib": "RIB fournisseur", - "technique_provider": "Prestataire", + "technique_provider": "Prestataire", "technique_provideraddress": "Adresse prestataire", "technique_providercontact": "Contact prestataire", - "technique_providerrib": "RIB prestataire", - "transport_carrier": "Transporteur", - "transport_carrieraddress": "Adresse transporteur", - "transport_carriercontact": "Contact transporteur", - "transport_carrierprice": "Prix transporteur" + "technique_providerrib": "RIB prestataire", + "transport_carrier": "Transporteur", + "transport_carrieraddress": "Adresse transporteur", + "transport_carriercontact": "Contact transporteur", + "transport_carrierprice": "Prix transporteur" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", diff --git a/frontend/modules/transport/components/CarrierPriceBlock.vue b/frontend/modules/transport/components/CarrierPriceBlock.vue new file mode 100644 index 0000000..09001ba --- /dev/null +++ b/frontend/modules/transport/components/CarrierPriceBlock.vue @@ -0,0 +1,312 @@ + + + diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index 0608410..134b925 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -38,7 +38,8 @@ vi.stubGlobal('useToast', () => ({ const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact') -const { emptyCarrierContact } = await import('../../types/carrierForm') +const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice') +const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm') describe('useCarrierForm', () => { beforeEach(() => { @@ -699,3 +700,172 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => { expect(form.contacts.value).toHaveLength(1) }) }) + +describe('carrierPrice (util) — bascule CLIENT/FOURNISSEUR + champs requis par branche', () => { + const CLIENT = '/api/clients/3' + const CLIENT_ADDR = '/api/client_addresses/8' + const SUPPLIER = '/api/suppliers/5' + const SUPPLIER_ADDR = '/api/supplier_addresses/9' + const SITE = '/api/sites/1' + + it('buildCarrierPricePayload CLIENT : branche client envoyée, branche fournisseur à null', () => { + const body = buildCarrierPricePayload({ + ...emptyCarrierPrice(), + direction: 'CLIENT', + clientIri: CLIENT, + clientDeliveryAddressIri: CLIENT_ADDR, + departureSiteIri: SITE, + containerType: 'BENNE', + pricingUnit: 'FORFAIT', + price: '120.00', + priceState: 'EN_COURS', + }) + expect(body).toMatchObject({ + direction: 'CLIENT', + client: CLIENT, + clientDeliveryAddress: CLIENT_ADDR, + departureSite: SITE, + supplier: null, + supplierSupplyAddress: null, + deliverySite: null, + containerType: 'BENNE', + pricingUnit: 'FORFAIT', + price: '120.00', + priceState: 'EN_COURS', + }) + }) + + it('buildCarrierPricePayload FOURNISSEUR : branche fournisseur envoyée, branche client à null', () => { + const body = buildCarrierPricePayload({ + ...emptyCarrierPrice(), + direction: 'FOURNISSEUR', + supplierIri: SUPPLIER, + supplierSupplyAddressIri: SUPPLIER_ADDR, + deliverySiteIri: SITE, + containerType: 'FOND_MOUVANT', + pricingUnit: 'TONNE', + price: '45', + priceState: 'VALIDE', + }) + expect(body).toMatchObject({ + direction: 'FOURNISSEUR', + supplier: SUPPLIER, + supplierSupplyAddress: SUPPLIER_ADDR, + deliverySite: SITE, + client: null, + clientDeliveryAddress: null, + departureSite: null, + }) + }) + + it('isCarrierPriceValid : faux si branche incomplète, vrai si branche complète + communs', () => { + const base = { ...emptyCarrierPrice(), containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '10', priceState: 'EN_COURS' } + // Direction non choisie → invalide. + expect(isCarrierPriceValid(base)).toBe(false) + // CLIENT sans adresse/site → invalide. + expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT })).toBe(false) + // CLIENT complet → valide. + expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE })).toBe(true) + // FOURNISSEUR complet → valide. + expect(isCarrierPriceValid({ ...base, direction: 'FOURNISSEUR', supplierIri: SUPPLIER, supplierSupplyAddressIri: SUPPLIER_ADDR, deliverySiteIri: SITE })).toBe(true) + // Prix manquant → invalide même branche complète. + expect(isCarrierPriceValid({ ...emptyCarrierPrice(), direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE, containerType: 'BENNE', pricingUnit: 'FORFAIT', priceState: 'EN_COURS' })).toBe(false) + }) +}) + +describe('useCarrierForm — onglet Prix (ERP-169)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + mockDelete.mockReset() + }) + + function createdForm() { + const form = useCarrierForm() + form.carrierId.value = 7 + return form + } + + it('démarre vide ; « + Nouveau prix » autorisé sur liste vide, bloqué si dernier bloc incomplet', () => { + const form = createdForm() + expect(form.prices.value).toHaveLength(0) + expect(form.canAddPrice.value).toBe(true) + + form.addPrice() + expect(form.prices.value).toHaveLength(1) + // Bloc vide → on ne peut pas en ajouter un autre. + expect(form.canAddPrice.value).toBe(false) + form.addPrice() + expect(form.prices.value).toHaveLength(1) + }) + + it('submitPrices : POST des nouveaux prix (branche CLIENT), capture l\'id, finalise', async () => { + mockPost.mockResolvedValueOnce({ id: 50 }) + const form = createdForm() + form.addPrice() + const p = form.prices.value[0] + if (p) { + p.direction = 'CLIENT' + p.clientIri = '/api/clients/3' + p.clientDeliveryAddressIri = '/api/client_addresses/8' + p.departureSiteIri = '/api/sites/1' + p.containerType = 'BENNE' + p.pricingUnit = 'FORFAIT' + p.price = '120' + p.priceState = 'EN_COURS' + } + + const ok = await form.submitPrices(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/carriers/7/prices') + expect(body).toMatchObject({ direction: 'CLIENT', client: '/api/clients/3', supplier: null }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(form.prices.value[0]?.id).toBe(50) + expect(form.isValidated('prices')).toBe(true) + }) + + it('submitPrices : PATCH des prix existants sur /carrier_prices/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + form.addPrice() + const p = form.prices.value[0] + if (p) { p.id = 50; p.direction = 'FOURNISSEUR'; p.supplierIri = '/api/suppliers/5'; p.price = '10' } + + await form.submitPrices(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/carrier_prices/50', expect.objectContaining({ direction: 'FOURNISSEUR' }), { toast: false }) + }) + + it('submitPrices : mappe les 422 par ligne et ne finalise pas', async () => { + mockPost.mockRejectedValueOnce({ + response: { status: 422, _data: { violations: [{ propertyPath: 'departureSite', message: 'Le site de depart est obligatoire pour un prix client.' }] } }, + }) + const form = createdForm() + form.addPrice() + const p = form.prices.value[0] + if (p) { p.direction = 'CLIENT'; p.clientIri = '/api/clients/3'; p.price = '10' } + + const ok = await form.submitPrices(vi.fn()) + + expect(ok).toBe(false) + expect(form.priceErrors.value[0]?.departureSite).toBe('Le site de depart est obligatoire pour un prix client.') + expect(form.isValidated('prices')).toBe(false) + }) + + it('removePrice : DELETE /carrier_prices/{id} puis retrait du bloc', async () => { + mockDelete.mockResolvedValueOnce({}) + const form = createdForm() + form.addPrice() + const p = form.prices.value[0] + if (p) { p.id = 77; p.direction = 'CLIENT'; p.clientIri = '/api/clients/3'; p.clientDeliveryAddressIri = '/api/client_addresses/8'; p.departureSiteIri = '/api/sites/1'; p.containerType = 'BENNE'; p.pricingUnit = 'FORFAIT'; p.price = '10'; p.priceState = 'EN_COURS' } + form.addPrice() + + await form.removePrice(0) + + expect(mockDelete).toHaveBeenCalledWith('/carrier_prices/77', {}, { toast: false }) + expect(form.prices.value).toHaveLength(1) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 49f0a37..787cff9 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -7,14 +7,17 @@ import { emptyCarrierAddressCopy, emptyCarrierContact, emptyCarrierMain, + emptyCarrierPrice, type CarrierAddressCopy, type CarrierAddressFormDraft, type CarrierContactFormDraft, type CarrierMainDraft, type CarrierMainResponse, + type CarrierPriceFormDraft, } from '~/modules/transport/types/carrierForm' 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 type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' /** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */ @@ -465,6 +468,81 @@ export function useCarrierForm() { } } + // ── Onglet Prix (ERP-169) ───────────────────────────────────────────────── + // Démarre VIDE : aucun bloc auto, l'utilisateur ajoute via « + Nouveau prix ». + const prices = ref([]) + // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. + const priceErrors = ref[]>([]) + + // « + Nouveau prix » : autorisé si la liste est vide, sinon le dernier bloc doit + // être valide (branche complète + prix — RG-4.09→4.11, pré-check léger). + const canAddPrice = computed(() => { + const last = prices.value[prices.value.length - 1] + return last === undefined || isCarrierPriceValid(last) + }) + + function addPrice(): void { + if (canAddPrice.value) { + prices.value.push(emptyCarrierPrice()) + } + } + + /** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */ + async function removePrice(index: number): Promise { + await removeCollectionRow({ + rows: prices.value, + errors: priceErrors.value, + index, + endpoint: '/carrier_prices', + deleteRow: url => api.delete(url, {}, { toast: false }), + makeEmpty: emptyCarrierPrice, + onError: notifyRemovalError, + }) + } + + /** + * Valide l'onglet Prix : POST des nouveaux prix sur /carriers/{id}/prices, PATCH + * des existants sur /carrier_prices/{id} (groupe carrier:write:prices). La + * cohérence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11) est re-validée back → + * 422 par ligne. Onglet Prix optionnel : une liste vide finalise sans appel. + * Retourne true si l'onglet a été validé (création terminée). + */ + async function submitPrices(onError: (error: unknown) => void): Promise { + if (carrierId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasError = await submitRows( + prices.value, + priceErrors, + async (price) => { + const body = buildCarrierPricePayload(price) + if (price.id === null) { + const created = await api.post<{ id: number }>( + `/carriers/${carrierId.value}/prices`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + price.id = created.id + } + else { + await api.patch(`/carrier_prices/${price.id}`, body, { toast: false }) + } + }, + onError, + ) + if (hasError) { + return false + } + completeTab('prices') + 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), @@ -571,6 +649,13 @@ export function useCarrierForm() { addContact, removeContact, submitContacts, + // prix + prices, + priceErrors, + canAddPrice, + addPrice, + removePrice, + submitPrices, // actions validateMainFront, buildMainPayload, diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 4f0360c..afdd702 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -240,7 +240,43 @@ - + + + +