Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e0e81130b | |||
| 8daf0ff5d4 | |||
| fb9c15c52a | |||
| e1712465f1 | |||
| 6ff5b13ce2 | |||
| a26bb09ee1 | |||
| 07e0bcbcce |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.131'
|
app.version: '0.1.136'
|
||||||
|
|||||||
@@ -529,7 +529,8 @@
|
|||||||
"createSuccess": "Transporteur créé avec succès",
|
"createSuccess": "Transporteur créé avec succès",
|
||||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||||
"addressSaved": "Adresse enregistrée",
|
"addressSaved": "Adresse enregistrée",
|
||||||
"contactSaved": "Contact enregistré"
|
"contactSaved": "Contact enregistré",
|
||||||
|
"priceSaved": "Prix enregistré"
|
||||||
},
|
},
|
||||||
"containerType": {
|
"containerType": {
|
||||||
"BENNE": "Benne",
|
"BENNE": "Benne",
|
||||||
@@ -608,6 +609,41 @@
|
|||||||
"message": "Cette suppression est définitive. Confirmer ?",
|
"message": "Cette suppression est définitive. Confirmer ?",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Supprimer"
|
"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",
|
||||||
|
"errors": {
|
||||||
|
"direction": "Le sens du prix est obligatoire.",
|
||||||
|
"client": "Le client est obligatoire pour un prix client.",
|
||||||
|
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
|
||||||
|
"departureSite": "Le site de départ est obligatoire pour un prix client.",
|
||||||
|
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
|
||||||
|
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
|
||||||
|
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
|
||||||
|
"containerType": "Le type de contenant est obligatoire.",
|
||||||
|
"pricingUnit": "L'unité de tarification est obligatoire.",
|
||||||
|
"price": "Le prix est obligatoire.",
|
||||||
|
"priceState": "L'état du prix est obligatoire."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<!-- Suppression : modal de confirmation côté parent. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
||||||
|
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
||||||
|
case « Affréter ». Pas de label de groupe. -->
|
||||||
|
<div>
|
||||||
|
<div class="flex h-12 items-center gap-6">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.direction"
|
||||||
|
name="price-direction"
|
||||||
|
value="CLIENT"
|
||||||
|
:label="t('transport.carriers.form.price.directionClient')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="onDirectionChange"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.direction"
|
||||||
|
name="price-direction"
|
||||||
|
value="FOURNISSEUR"
|
||||||
|
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="onDirectionChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branche CLIENT (RG-4.10). -->
|
||||||
|
<template v-if="model.direction === 'CLIENT'">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.clientIri"
|
||||||
|
:options="clientOptions"
|
||||||
|
:label="t('transport.carriers.form.price.client')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.client"
|
||||||
|
@update:model-value="onClientChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.clientDeliveryAddressIri"
|
||||||
|
:options="clientAddressOptions"
|
||||||
|
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.clientDeliveryAddress"
|
||||||
|
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.departureSiteIri"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('transport.carriers.form.price.departureSite')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.departureSite"
|
||||||
|
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
||||||
|
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.supplierIri"
|
||||||
|
:options="supplierOptions"
|
||||||
|
:label="t('transport.carriers.form.price.supplier')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.supplier"
|
||||||
|
@update:model-value="onSupplierChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.supplierSupplyAddressIri"
|
||||||
|
:options="supplierAddressOptions"
|
||||||
|
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.supplierSupplyAddress"
|
||||||
|
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.deliverySiteIri"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('transport.carriers.form.price.deliverySite')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.deliverySite"
|
||||||
|
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
||||||
|
<template v-if="model.direction !== null">
|
||||||
|
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
||||||
|
<div>
|
||||||
|
<div class="flex h-12 items-center gap-4">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.containerType"
|
||||||
|
name="price-container"
|
||||||
|
value="BENNE"
|
||||||
|
:label="t('transport.carriers.containerType.BENNE')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.containerType"
|
||||||
|
name="price-container"
|
||||||
|
value="FOND_MOUVANT"
|
||||||
|
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
||||||
|
<div>
|
||||||
|
<div class="flex h-12 items-center gap-4">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.pricingUnit"
|
||||||
|
name="price-unit"
|
||||||
|
value="FORFAIT"
|
||||||
|
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="model.pricingUnit"
|
||||||
|
name="price-unit"
|
||||||
|
value="TONNE"
|
||||||
|
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||||
|
:disabled="readonly"
|
||||||
|
group-class="mt-0"
|
||||||
|
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioInputAmount
|
||||||
|
:model-value="model.price"
|
||||||
|
:label="t('transport.carriers.form.price.price')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.price"
|
||||||
|
@update:model-value="(v: string) => update('price', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.priceState"
|
||||||
|
:options="priceStateOptions"
|
||||||
|
:label="t('transport.carriers.form.price.priceState')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.priceState"
|
||||||
|
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon du prix (v-model). */
|
||||||
|
modelValue: CarrierPriceFormDraft
|
||||||
|
/** Clients disponibles (IRI en value). */
|
||||||
|
clientOptions: SelectOption[]
|
||||||
|
/** Fournisseurs disponibles (IRI en value). */
|
||||||
|
supplierOptions: SelectOption[]
|
||||||
|
/** Sites Starseed (3 sites — IRI en value). */
|
||||||
|
siteOptions: SelectOption[]
|
||||||
|
removable?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: CarrierPriceFormDraft]
|
||||||
|
'remove': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
const priceStateOptions = computed<SelectOption[]>(() => [
|
||||||
|
{ value: 'EN_COURS', label: t('transport.carriers.form.price.stateEnCours') },
|
||||||
|
{ value: 'VALIDE', label: t('transport.carriers.form.price.stateValide') },
|
||||||
|
{ value: 'NON_VALIDE', label: t('transport.carriers.form.price.stateNonValide') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Adresses chargées à la volée pour le client / fournisseur sélectionné (par bloc).
|
||||||
|
const clientAddressOptions = ref<SelectOption[]>([])
|
||||||
|
const supplierAddressOptions = ref<SelectOption[]>([])
|
||||||
|
|
||||||
|
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
|
||||||
|
function update<K extends keyof CarrierPriceFormDraft>(field: K, value: CarrierPriceFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changement de sens : réinitialise les DEUX branches (cohérence CHECK BDD). */
|
||||||
|
function onDirectionChange(value: string | number | boolean | null): void {
|
||||||
|
const direction = value === 'CLIENT' || value === 'FOURNISSEUR' ? value : null
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
direction,
|
||||||
|
clientIri: null,
|
||||||
|
clientDeliveryAddressIri: null,
|
||||||
|
departureSiteIri: null,
|
||||||
|
supplierIri: null,
|
||||||
|
supplierSupplyAddressIri: null,
|
||||||
|
deliverySiteIri: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélection d'un client → maj IRI + reset de l'adresse de livraison (autre client). */
|
||||||
|
function onClientChange(value: string | number | null): void {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
clientIri: value === null ? null : String(value),
|
||||||
|
clientDeliveryAddressIri: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélection d'un fournisseur → maj IRI + reset de l'adresse d'appro. */
|
||||||
|
function onSupplierChange(value: string | number | null): void {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
supplierIri: value === null ? null : String(value),
|
||||||
|
supplierSupplyAddressIri: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse détail (client / fournisseur) embarquant ses adresses. */
|
||||||
|
interface ParentWithAddresses {
|
||||||
|
addresses?: { '@id': string, street?: string | null, postalCode?: string | null, city?: string | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe les adresses embarquées en options (IRI en value, voie · CP · ville en label). */
|
||||||
|
function toAddressOptions(parent: ParentWithAddresses): SelectOption[] {
|
||||||
|
return (parent.addresses ?? []).map(a => ({
|
||||||
|
value: a['@id'],
|
||||||
|
label: [a.street, a.postalCode, a.city].filter(Boolean).join(' · ') || a['@id'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les adresses d'un parent (client/fournisseur) à la volée via son détail
|
||||||
|
* (GET de l'IRI, qui embarque `addresses`). Échec → liste vide (non bloquant).
|
||||||
|
*/
|
||||||
|
async function loadAddresses(iri: string | null, target: typeof clientAddressOptions): Promise<void> {
|
||||||
|
if (!iri) {
|
||||||
|
target.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// L'IRI est absolue (/api/clients/5) ; useApi préfixe déjà /api → on le retire.
|
||||||
|
const path = iri.replace(/^\/api/, '')
|
||||||
|
const data = await api.get<ParentWithAddresses>(path, {}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||||
|
target.value = toAddressOptions(data)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
target.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharge les adresses quand le client / fournisseur change (immediate pour le
|
||||||
|
// pré-remplissage en édition).
|
||||||
|
watch(() => props.modelValue.clientIri, iri => loadAddresses(iri, clientAddressOptions), { immediate: true })
|
||||||
|
watch(() => props.modelValue.supplierIri, iri => loadAddresses(iri, supplierAddressOptions), { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -38,7 +38,8 @@ vi.stubGlobal('useToast', () => ({
|
|||||||
|
|
||||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
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', () => {
|
describe('useCarrierForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -699,3 +700,215 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
|||||||
expect(form.contacts.value).toHaveLength(1)
|
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, direction: null })).toBe(false)
|
||||||
|
// Sens CLIENT par défaut mais branche incomplète → 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 avec un bloc CLIENT par défaut ; « + Nouveau prix » bloqué tant qu\'il est incomplet', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
// Un bloc présent d'office, sens CLIENT pré-sélectionné.
|
||||||
|
expect(form.prices.value).toHaveLength(1)
|
||||||
|
expect(form.prices.value[0]?.direction).toBe('CLIENT')
|
||||||
|
// Bloc incomplet → on ne peut pas en ajouter un autre.
|
||||||
|
expect(form.canAddPrice.value).toBe(false)
|
||||||
|
form.addPrice()
|
||||||
|
expect(form.prices.value).toHaveLength(1)
|
||||||
|
|
||||||
|
// Une fois le bloc complété, l'ajout est autorisé.
|
||||||
|
const p = form.prices.value[0]
|
||||||
|
if (p) {
|
||||||
|
p.clientIri = '/api/clients/3'
|
||||||
|
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
||||||
|
p.departureSiteIri = '/api/sites/1'
|
||||||
|
p.price = '120'
|
||||||
|
p.priceState = 'EN_COURS'
|
||||||
|
}
|
||||||
|
expect(form.canAddPrice.value).toBe(true)
|
||||||
|
form.addPrice()
|
||||||
|
expect(form.prices.value).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
const p = form.prices.value[0]
|
||||||
|
if (p) {
|
||||||
|
p.id = 50
|
||||||
|
p.direction = 'FOURNISSEUR'
|
||||||
|
p.supplierIri = '/api/suppliers/5'
|
||||||
|
p.supplierSupplyAddressIri = '/api/supplier_addresses/9'
|
||||||
|
p.deliverySiteIri = '/api/sites/1'
|
||||||
|
p.price = '10'
|
||||||
|
p.priceState = 'VALIDE'
|
||||||
|
}
|
||||||
|
|
||||||
|
await form.submitPrices(vi.fn())
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/carrier_prices/50', expect.objectContaining({ direction: 'FOURNISSEUR' }), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('front : bloc prix incomplet → erreurs inline sous chaque champ requis, pas d\'appel back', async () => {
|
||||||
|
const form = createdForm()
|
||||||
|
// Bloc CLIENT par défaut, rien d'autre rempli.
|
||||||
|
const ok = await form.submitPrices(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
const errs = form.priceErrors.value[0]
|
||||||
|
expect(errs?.client).toBeTruthy()
|
||||||
|
expect(errs?.clientDeliveryAddress).toBeTruthy()
|
||||||
|
expect(errs?.departureSite).toBeTruthy()
|
||||||
|
expect(errs?.price).toBeTruthy()
|
||||||
|
expect(errs?.priceState).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitPrices : mappe les 422 back par ligne (appartenance adresse) et ne finalise pas', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: { status: 422, _data: { violations: [{ propertyPath: 'clientDeliveryAddress', message: 'L\'adresse de livraison doit appartenir au client selectionne.' }] } },
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
// Tous les champs requis remplis (le pré-check front passe) ; le back 422 sur
|
||||||
|
// une RG qu'il est seul à connaître (appartenance de l'adresse au client).
|
||||||
|
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.price = '10'
|
||||||
|
p.priceState = 'EN_COURS'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await form.submitPrices(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.priceErrors.value[0]?.clientDeliveryAddress).toBe('L\'adresse de livraison doit appartenir au client selectionne.')
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ import {
|
|||||||
emptyCarrierAddressCopy,
|
emptyCarrierAddressCopy,
|
||||||
emptyCarrierContact,
|
emptyCarrierContact,
|
||||||
emptyCarrierMain,
|
emptyCarrierMain,
|
||||||
|
emptyCarrierPrice,
|
||||||
type CarrierAddressCopy,
|
type CarrierAddressCopy,
|
||||||
type CarrierAddressFormDraft,
|
type CarrierAddressFormDraft,
|
||||||
type CarrierContactFormDraft,
|
type CarrierContactFormDraft,
|
||||||
type CarrierMainDraft,
|
type CarrierMainDraft,
|
||||||
type CarrierMainResponse,
|
type CarrierMainResponse,
|
||||||
|
type CarrierPriceFormDraft,
|
||||||
} from '~/modules/transport/types/carrierForm'
|
} from '~/modules/transport/types/carrierForm'
|
||||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
||||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
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'
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
||||||
@@ -465,6 +468,123 @@ export function useCarrierForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onglet Prix (ERP-169) ─────────────────────────────────────────────────
|
||||||
|
// Un bloc présent par défaut (sens CLIENT pré-sélectionné). L'utilisateur ajoute
|
||||||
|
// les suivants via « + Nouveau prix ».
|
||||||
|
const prices = ref<CarrierPriceFormDraft[]>([emptyCarrierPrice()])
|
||||||
|
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||||
|
const priceErrors = ref<Record<string, string>[]>([])
|
||||||
|
|
||||||
|
// « + 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pré-validation FRONT d'un bloc prix (ERP-101) : renvoie les erreurs inline par
|
||||||
|
* champ obligatoire (sens + branche active + communs). Nécessaire car côté back
|
||||||
|
* l'Assert\NotBlank sur les scalaires (price/priceState...) court-circuite la
|
||||||
|
* validation de branche du CarrierPriceProcessor : le 422 ne porterait jamais
|
||||||
|
* client/supplier/adresses en même temps. Messages alignés sur le back.
|
||||||
|
*/
|
||||||
|
function validatePriceRow(price: CarrierPriceFormDraft): Record<string, string> {
|
||||||
|
const errs: Record<string, string> = {}
|
||||||
|
const msg = (key: string): string => t(`transport.carriers.form.price.errors.${key}`)
|
||||||
|
|
||||||
|
if (!price.direction) {
|
||||||
|
errs.direction = msg('direction')
|
||||||
|
}
|
||||||
|
if (price.direction === 'CLIENT') {
|
||||||
|
if (!price.clientIri) errs.client = msg('client')
|
||||||
|
if (!price.clientDeliveryAddressIri) errs.clientDeliveryAddress = msg('clientDeliveryAddress')
|
||||||
|
if (!price.departureSiteIri) errs.departureSite = msg('departureSite')
|
||||||
|
}
|
||||||
|
if (price.direction === 'FOURNISSEUR') {
|
||||||
|
if (!price.supplierIri) errs.supplier = msg('supplier')
|
||||||
|
if (!price.supplierSupplyAddressIri) errs.supplierSupplyAddress = msg('supplierSupplyAddress')
|
||||||
|
if (!price.deliverySiteIri) errs.deliverySite = msg('deliverySite')
|
||||||
|
}
|
||||||
|
if (!price.containerType) errs.containerType = msg('containerType')
|
||||||
|
if (!price.pricingUnit) errs.pricingUnit = msg('pricingUnit')
|
||||||
|
if (!price.price || price.price.trim() === '') errs.price = msg('price')
|
||||||
|
if (!price.priceState) errs.priceState = msg('priceState')
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */
|
||||||
|
async function removePrice(index: number): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
if (carrierId.value === null || tabSubmitting.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pré-check front : affiche toutes les obligations sous leur champ d'un coup
|
||||||
|
// (le back ne peut pas tout renvoyer en une passe — cf. validatePriceRow).
|
||||||
|
const frontErrors = prices.value.map(validatePriceRow)
|
||||||
|
if (frontErrors.some(errs => Object.keys(errs).length > 0)) {
|
||||||
|
priceErrors.value = frontErrors
|
||||||
|
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 /
|
* 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),
|
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
|
||||||
@@ -571,6 +691,13 @@ export function useCarrierForm() {
|
|||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
removeContact,
|
||||||
submitContacts,
|
submitContacts,
|
||||||
|
// prix
|
||||||
|
prices,
|
||||||
|
priceErrors,
|
||||||
|
canAddPrice,
|
||||||
|
addPrice,
|
||||||
|
removePrice,
|
||||||
|
submitPrices,
|
||||||
// actions
|
// actions
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
|
|||||||
@@ -240,7 +240,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Prix : contenu au ticket suivant. -->
|
<!-- Onglet Prix (ERP-169) : blocs multiples, branche CLIENT/FOURNISSEUR
|
||||||
|
(RG-4.09→4.11). Démarre vide ; l'utilisateur ajoute via « + Nouveau prix ». -->
|
||||||
|
<template #prices>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<CarrierPriceBlock
|
||||||
|
v-for="(price, index) in prices"
|
||||||
|
:key="index"
|
||||||
|
:model-value="price"
|
||||||
|
:client-options="clientOptions"
|
||||||
|
:supplier-options="supplierOptions"
|
||||||
|
:site-options="siteOptions"
|
||||||
|
:removable="!isValidated('prices')"
|
||||||
|
:readonly="isValidated('prices')"
|
||||||
|
:errors="priceErrors[index]"
|
||||||
|
@update:model-value="(v) => prices[index] = v"
|
||||||
|
@remove="askRemovePrice(index)"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('prices')" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('transport.carriers.form.price.add')"
|
||||||
|
:disabled="!canAddPrice"
|
||||||
|
@click="addPrice"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('transport.carriers.form.submit')"
|
||||||
|
:disabled="tabSubmitting || carrierId === null"
|
||||||
|
@click="onSubmitPrices"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Plus d'onglet placeholder : tous les onglets ont leur contenu. -->
|
||||||
<template
|
<template
|
||||||
v-for="key in placeholderTabs"
|
v-for="key in placeholderTabs"
|
||||||
:key="key"
|
:key="key"
|
||||||
@@ -305,6 +341,7 @@ import { extractApiErrorMessage } from '~/shared/utils/api'
|
|||||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||||
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
@@ -355,6 +392,12 @@ const {
|
|||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
removeContact,
|
||||||
submitContacts,
|
submitContacts,
|
||||||
|
prices,
|
||||||
|
priceErrors,
|
||||||
|
canAddPrice,
|
||||||
|
addPrice,
|
||||||
|
removePrice,
|
||||||
|
submitPrices,
|
||||||
submitMain,
|
submitMain,
|
||||||
applyQualimatSelection,
|
applyQualimatSelection,
|
||||||
} = useCarrierForm()
|
} = useCarrierForm()
|
||||||
@@ -448,11 +491,42 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|||||||
disabled: index > unlockedIndex.value,
|
disabled: index > unlockedIndex.value,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
// Onglets dont le contenu arrive aux tickets suivants (Prix).
|
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
|
||||||
const placeholderTabs = computed(() => tabKeys.value.filter(
|
const placeholderTabs = computed(() => tabKeys.value.filter(
|
||||||
key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts',
|
key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts' && key !== 'prices',
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// ── Référentiels de l'onglet Prix (clients / fournisseurs / sites) ───────────
|
||||||
|
const clientOptions = ref<SelectOption[]>([])
|
||||||
|
const supplierOptions = ref<SelectOption[]>([])
|
||||||
|
const siteOptions = ref<SelectOption[]>([])
|
||||||
|
|
||||||
|
/** Charge un référentiel paginé (?pagination=false) et mappe en options { IRI, libellé }. */
|
||||||
|
async function loadOptions(
|
||||||
|
url: string,
|
||||||
|
target: typeof clientOptions,
|
||||||
|
labelOf: (m: Record<string, unknown>) => string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ member?: Record<string, unknown>[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
target.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
|
||||||
|
function loadPriceReferentials(): void {
|
||||||
|
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
||||||
|
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
|
||||||
|
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
|
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
|
||||||
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
|
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
|
||||||
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
|
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
|
||||||
@@ -478,6 +552,7 @@ async function loadCountries(): Promise<void> {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCountries().catch(() => {})
|
loadCountries().catch(() => {})
|
||||||
|
loadPriceReferentials()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
|
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
|
||||||
@@ -535,6 +610,22 @@ function askRemoveContact(index: number): void {
|
|||||||
deleteConfirm.open = true
|
deleteConfirm.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Valide l'onglet Prix (POST/PATCH par ligne ; avance gérée par le composable). */
|
||||||
|
async function onSubmitPrices(): Promise<void> {
|
||||||
|
const ok = await submitPrices(error => toast.error({
|
||||||
|
title: t('transport.carriers.toast.error'),
|
||||||
|
message: apiErrorMessage(error),
|
||||||
|
}))
|
||||||
|
if (ok) {
|
||||||
|
toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemovePrice(index: number): void {
|
||||||
|
deleteConfirm.action = () => { void removePrice(index) }
|
||||||
|
deleteConfirm.open = true
|
||||||
|
}
|
||||||
|
|
||||||
function runDeleteConfirm(): void {
|
function runDeleteConfirm(): void {
|
||||||
deleteConfirm.action?.()
|
deleteConfirm.action?.()
|
||||||
deleteConfirm.action = null
|
deleteConfirm.action = null
|
||||||
|
|||||||
@@ -129,6 +129,51 @@ export function emptyCarrierContact(): CarrierContactFormDraft {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brouillon d'un bloc Prix (onglet Prix, ERP-169 — RG-4.09→4.11). `direction`
|
||||||
|
* pilote la branche active : CLIENT (client + adresse de livraison + site de
|
||||||
|
* départ) ou FOURNISSEUR (fournisseur + adresse d'appro + site de livraison). Les
|
||||||
|
* relations partent au back en IRI (string). `price` est une chaîne (MalioInputAmount).
|
||||||
|
*/
|
||||||
|
export interface CarrierPriceFormDraft {
|
||||||
|
id: number | null
|
||||||
|
direction: 'CLIENT' | 'FOURNISSEUR' | null
|
||||||
|
// Branche CLIENT (RG-4.10).
|
||||||
|
clientIri: string | null
|
||||||
|
clientDeliveryAddressIri: string | null
|
||||||
|
departureSiteIri: string | null
|
||||||
|
// Branche FOURNISSEUR (RG-4.11).
|
||||||
|
supplierIri: string | null
|
||||||
|
supplierSupplyAddressIri: string | null
|
||||||
|
deliverySiteIri: string | null
|
||||||
|
// Communs (toujours requis).
|
||||||
|
containerType: string | null
|
||||||
|
pricingUnit: string | null
|
||||||
|
price: string | null
|
||||||
|
priceState: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brouillon de prix vide (état initial d'un bloc Prix : tout masqué tant que direction null). */
|
||||||
|
export function emptyCarrierPrice(): CarrierPriceFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
// Défaut métier : sens CLIENT pré-sélectionné (un bloc prix CLIENT est présent
|
||||||
|
// d'office à l'ouverture de l'onglet).
|
||||||
|
direction: 'CLIENT',
|
||||||
|
clientIri: null,
|
||||||
|
clientDeliveryAddressIri: null,
|
||||||
|
departureSiteIri: null,
|
||||||
|
supplierIri: null,
|
||||||
|
supplierSupplyAddressIri: null,
|
||||||
|
deliverySiteIri: null,
|
||||||
|
// Défauts métier : Benne + Forfait pré-sélectionnés à l'ajout d'un bloc prix.
|
||||||
|
containerType: 'BENNE',
|
||||||
|
pricingUnit: 'FORFAIT',
|
||||||
|
price: null,
|
||||||
|
priceState: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
||||||
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Prix transporteur (M4 Transport, ERP-169 — RG-4.09→4.11).
|
||||||
|
* Une ligne porte une branche CLIENT ou FOURNISSEUR selon `direction` ; les champs
|
||||||
|
* de la branche INACTIVE doivent toujours partir à null (CHECK BDD
|
||||||
|
* chk_carrier_price_client_branch / supplier_branch). Testables sans Vue ni API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
||||||
|
|
||||||
|
/** Vrai si une chaîne porte au moins un caractère non-espace. */
|
||||||
|
function isFilled(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de la sous-ressource prix (groupe `carrier:write:prices`). Envoie les
|
||||||
|
* communs + UNIQUEMENT la branche active (l'autre branche à null, exigée par les
|
||||||
|
* CHECK BDD). Les relations partent en IRI (string|null).
|
||||||
|
*
|
||||||
|
* IMPORTANT : les scalaires obligatoires (direction / containerType / pricingUnit /
|
||||||
|
* price / priceState) sont OMIS s'ils sont vides — on n'envoie JAMAIS `null` sur un
|
||||||
|
* champ string. Sinon API Platform lève un 400 « The type of the "price" attribute
|
||||||
|
* must be "string", "NULL" given. » AVANT la validation (non mappable inline). Omis,
|
||||||
|
* le champ reste null côté entité → l'Assert\NotBlank renvoie un 422 propre rattaché
|
||||||
|
* au champ, affiché sous l'input comme les autres blocs (ERP-101). Le back re-valide
|
||||||
|
* aussi l'obligation conditionnelle de branche + l'appartenance de l'adresse.
|
||||||
|
*/
|
||||||
|
export function buildCarrierPricePayload(price: CarrierPriceFormDraft): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
// Scalaires : présents seulement si remplis (jamais `null` → évite le 400 de type).
|
||||||
|
if (isFilled(price.direction)) payload.direction = price.direction
|
||||||
|
if (isFilled(price.containerType)) payload.containerType = price.containerType
|
||||||
|
if (isFilled(price.pricingUnit)) payload.pricingUnit = price.pricingUnit
|
||||||
|
if (isFilled(price.price)) payload.price = price.price
|
||||||
|
if (isFilled(price.priceState)) payload.priceState = price.priceState
|
||||||
|
|
||||||
|
// Branche active en IRI (null toléré sur une relation, ne déclenche pas le 400 de
|
||||||
|
// type) ; branche inactive forcée à null (CHECK BDD chk_carrier_price_*_branch).
|
||||||
|
if (price.direction === 'CLIENT') {
|
||||||
|
payload.client = price.clientIri || null
|
||||||
|
payload.clientDeliveryAddress = price.clientDeliveryAddressIri || null
|
||||||
|
payload.departureSite = price.departureSiteIri || null
|
||||||
|
payload.supplier = null
|
||||||
|
payload.supplierSupplyAddress = null
|
||||||
|
payload.deliverySite = null
|
||||||
|
}
|
||||||
|
else if (price.direction === 'FOURNISSEUR') {
|
||||||
|
payload.supplier = price.supplierIri || null
|
||||||
|
payload.supplierSupplyAddress = price.supplierSupplyAddressIri || null
|
||||||
|
payload.deliverySite = price.deliverySiteIri || null
|
||||||
|
payload.client = null
|
||||||
|
payload.clientDeliveryAddress = null
|
||||||
|
payload.departureSite = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pré-check léger du gating « + Nouveau prix » : direction choisie, prix rempli, et
|
||||||
|
* branche active complète (client/adresse/site OU fournisseur/adresse/site). Le back
|
||||||
|
* reste la couche autoritaire (RG-4.09→4.11) ; ce pré-check évite d'empiler des
|
||||||
|
* blocs vides.
|
||||||
|
*/
|
||||||
|
export function isCarrierPriceValid(price: CarrierPriceFormDraft): boolean {
|
||||||
|
if (!isFilled(price.price) || !isFilled(price.containerType) || !isFilled(price.pricingUnit) || !isFilled(price.priceState)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (price.direction === 'CLIENT') {
|
||||||
|
return isFilled(price.clientIri) && isFilled(price.clientDeliveryAddressIri) && isFilled(price.departureSiteIri)
|
||||||
|
}
|
||||||
|
if (price.direction === 'FOURNISSEUR') {
|
||||||
|
return isFilled(price.supplierIri) && isFilled(price.supplierSupplyAddressIri) && isFilled(price.deliverySiteIri)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user