Compare commits

..

31 Commits

Author SHA1 Message Date
gitea-actions 55a3df140f chore: bump version to v0.1.137
Build & Push Docker Image / build (push) Successful in 53s
2026-06-18 08:51:28 +00:00
tristan fd89160c4b Merge pull request 'feat(transport) : consultation + modification transporteur (ERP-170)' (#129) from feat/erp-170-carrier-view-edit into develop
Auto Tag Develop / tag (push) Successful in 7s
2026-06-18 08:50:07 +00:00
tristan 8daf0ff5d4 Merge pull request 'feat(transport) : onglet prix transporteur (ERP-169)' (#128) from feat/erp-169-carrier-prices into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:49:57 +00:00
tristan 87c53c354b Merge pull request 'feat(transport) : onglet contacts transporteur (ERP-168)' (#127) from feat/erp-168-carrier-contacts into develop
Auto Tag Develop / tag (push) Successful in 9s
2026-06-18 08:49:50 +00:00
tristan f8b45cb30b Merge pull request 'feat(transport) : onglet adresses transporteur (ERP-167)' (#126) from feat/erp-167-carrier-addresses into develop
Auto Tag Develop / tag (push) Successful in 8s
2026-06-18 08:49:37 +00:00
tristan b6b5bb06e8 fix(transport) : affiche le message 409 (homonyme) à la restauration + virgule décimale dans sanitizeDecimal (ERP-170)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m7s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m41s
2026-06-17 16:08:02 +02:00
tristan fb9c15c52a fix(transport) : pré-validation front du bloc prix — erreurs inline sous tous les champs requis (selects branche) (ERP-169)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m4s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m34s
2026-06-17 16:08:02 +02:00
tristan c371057c0b style(transport) : tableau prix — réduit Adresse sites au profit d'Adresse livraisons (ERP-170) 2026-06-17 16:08:02 +02:00
tristan e1712465f1 fix(transport) : bloc prix — radios sens/contenant/tarif horizontaux et centrés (h-12) en colonne 1 (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 5125883e21 fix(transport) : tableau prix — corrige l'inversion Adresse sites / Adresse livraisons (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 6ff5b13ce2 fix(transport) : bloc prix par défaut (CLIENT), sens seul en ligne 1, payload omet scalaires vides (422 inline au lieu de 400) (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 5bbd4ddb47 style(transport) : tableau prix — libellés colonnes + élargit Transporteurs/Adresse livraisons, réduit Forfait/Tonne/Indexation/État (ERP-170) 2026-06-17 16:08:02 +02:00
tristan a26bb09ee1 fix(transport) : bloc prix — radios sans label de groupe, sens en colonne 1, défaut Benne/Forfait (ERP-169) 2026-06-17 16:08:02 +02:00
tristan 20296ac149 style(transport) : datatable qualimat table-fixed (radio étroit, colonnes égales) + icônes onglets prix/qualimat (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 07e0bcbcce feat(transport) : onglet prix transporteur (ERP-169) 2026-06-17 16:08:02 +02:00
tristan fe1d012548 style(transport) : tableau prix consultation en table-fixed (colonnes à parts égales, contenant étroit) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan d86dc69cf2 feat(transport) : consultation — contenant en radios lecture seule (aligné ajout/modif) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 07ed57f283 feat(transport) : contenant du formulaire principal en radios centrés (Benne par défaut) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan b5749520bc fix(transport) : consultation — disposition du bloc principal alignée sur l'ajout (LIOT, décharge col 3, affréter col 4) (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 02d2fde653 fix(transport) : indexation réellement plafonnée à 100 % — re-synchronise le champ amount contrôlé via :key (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 0d284fe488 fix(transport) : volume m³ en champ texte décimal + indexation en montant % plafonné à 100 (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 48ca963a9d fix(transport) : tableau prix — en-tête « Contenant » sur la colonne de groupe (ERP-170) 2026-06-17 16:08:02 +02:00
tristan b11968f5e5 fix(transport) : tableau prix — supprime la double bordure du bas + séparateur épais entre groupes Benne/Fond mouvant (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 5109b5f57a fix(transport) : tableau prix consultation — police/bordures/radius alignés MalioDataTable, colonne groupe en épine (ERP-170) 2026-06-17 16:08:02 +02:00
tristan d5a01ac85f fix(transport) : name de radio unique par bloc prix (useId) — plus de groupe partagé entre blocs (ERP-170) 2026-06-17 16:08:02 +02:00
tristan 7adf3a511a fix(transport) : tableau prix consultation — cellule de groupe fusionnée (Fond Mouvant/Benne) + colonnes maquette (ERP-170) 2026-06-17 16:08:02 +02:00
tristan e612eae391 feat(transport) : consultation + modification transporteur (ERP-170) 2026-06-17 16:08:02 +02:00
tristan f29266e5e8 fix(transport) : contact transporteur valide si prénom OU nom (alignement M1/M2/M3) (ERP-168)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m24s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m35s
2026-06-17 16:07:51 +02:00
tristan f27db02cb6 fix(transport) : règle « + Nouveau contact » alignée sur M1/M2/M3 (prénom OU nom) (ERP-168) 2026-06-17 16:07:51 +02:00
tristan 5765ba7178 feat(transport) : onglet contacts transporteur (ERP-168) 2026-06-17 16:07:51 +02:00
tristan ef996c3672 feat(transport) : onglet adresses transporteur (ERP-167)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m18s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m34s
2026-06-17 16:06:56 +02:00
26 changed files with 3941 additions and 89 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.133'
app.version: '0.1.137'
+122 -15
View File
@@ -527,7 +527,51 @@
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
"createSuccess": "Transporteur créé avec succès",
"integrateSuccess": "Transporteur QUALIMAT intégré"
"integrateSuccess": "Transporteur QUALIMAT intégré",
"addressSaved": "Adresse enregistrée",
"contactSaved": "Contact enregistré",
"priceSaved": "Prix enregistré",
"updateSuccess": "Transporteur mis à jour avec succès",
"archiveSuccess": "Transporteur archivé avec succès",
"restoreSuccess": "Transporteur restauré avec succès"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Consultation transporteur",
"back": "Retour au répertoire",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"confirmArchive": {
"title": "Archiver le transporteur",
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le transporteur",
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
},
"price": {
"group": "Contenant",
"carrier": "Transporteurs",
"aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons",
"forfait": "Forfait €",
"tonne": "Tonne €",
"indexation": "Indexation",
"state": "État du prix",
"export": "Exporter",
"empty": "Aucun prix pour ce transporteur."
}
},
"edit": {
"title": "Modifier le transporteur",
"back": "Retour à la consultation",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"save": "Enregistrer"
},
"containerType": {
"BENNE": "Benne",
@@ -578,6 +622,69 @@
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
},
"address": {
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetComplement": "Adresse complémentaire",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"add": "Nouvelle adresse",
"remove": "Supprimer l'adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"contact": {
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"email": "Email",
"add": "Nouveau contact",
"remove": "Supprimer le contact"
},
"confirmDelete": {
"title": "Supprimer ce bloc",
"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",
"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."
}
}
}
}
@@ -624,27 +731,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",
@@ -0,0 +1,237 @@
<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 cote 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.address.remove') }"
@click="$emit('remove')"
/>
<!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:required="true"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
<div aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<MalioInputText
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
interface RefOption {
value: string
label: string
}
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -0,0 +1,108 @@
<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 : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. -->
<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.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft
/** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean
/** Bloc en lecture seule (onglet validé). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: CarrierContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilité du template.
const model = computed(() => props.modelValue)
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Révèle le 2e numéro (max 1 secondaire, le « + » disparaît). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -0,0 +1,307 @@
<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-${uid}`"
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-${uid}`"
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-${uid}`"
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-${uid}`"
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-${uid}`"
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-${uid}`"
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, useId, 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()
// Identifiant unique par instance : les groupes de radios (sens / contenant / tarif)
// doivent avoir un `name` PROPRE à chaque bloc prix, sinon plusieurs blocs partagent
// le même groupe HTML et leurs radios se désélectionnent mutuellement.
const uid = useId()
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>
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyCarrierAddress } from '~/modules/transport/types/carrierForm'
import CarrierAddressBlock from '../CarrierAddressBlock.vue'
/**
* Tests de l'autocomplétion BAN du bloc Adresse transporteur (ERP-167) — réutilise
* `useAddressAutocomplete` (M1/M2/M3). On vérifie le NOMINAL (CP → ville) et le
* DÉGRADÉ (BAN indisponible → saisie libre + event `degraded`).
*/
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
const MalioInputTextStub = defineComponent({
name: 'MalioInputText',
props: { modelValue: { default: null }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'input-text', 'data-label': props.label, 'data-error': props.error })
},
})
const MalioSelectStub = defineComponent({
name: 'MalioSelect',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, label: { type: String, default: '' }, error: { type: String, default: '' } },
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'select', 'data-label': props.label, 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: { modelValue: { default: null }, options: { type: Array as () => { value: string }[], default: () => [] }, loading: { type: Boolean, default: false }, allowCreate: { type: Boolean, default: false } },
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-autocomplete', 'data-options': JSON.stringify(props.options.map(o => o.value)) })
},
})
function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, {
props: {
modelValue: { ...emptyCarrierAddress(), ...overrides },
countryOptions: [{ value: 'France', label: 'France' }],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputText: MalioInputTextStub,
MalioSelect: MalioSelectStub,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
/** Récupère le composant MalioInputText d'un label donné. */
function inputTextByLabel(wrapper: ReturnType<typeof mountBlock>, label: string) {
return wrapper.findAllComponents(MalioInputTextStub).find(c => c.props('label') === label)
}
describe('CarrierAddressBlock — autocomplétion ville (BAN) NOMINAL', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('saisie d\'un CP à 5 chiffres → searchCity + peuple le select Ville', async () => {
searchCityMock.mockResolvedValueOnce([{ city: 'Poitiers', postalCode: '86000' }])
const wrapper = mountBlock()
const cp = inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')
cp?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(searchCityMock).toHaveBeenCalledWith('86000')
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
const options = JSON.parse(citySelect?.attributes('data-options') ?? '[]')
expect(options).toContain('Poitiers')
expect(wrapper.emitted('degraded')).toBeUndefined()
})
it('n\'interroge pas la BAN sous 5 chiffres', async () => {
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '860')
await flushPromises()
expect(searchCityMock).not.toHaveBeenCalled()
})
})
describe('CarrierAddressBlock — autocomplétion DÉGRADÉE', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('BAN ville indisponible → bascule en saisie libre + émet « degraded »', async () => {
searchCityMock.mockRejectedValueOnce(new Error('BAN indisponible'))
const wrapper = mountBlock()
inputTextByLabel(wrapper, 'transport.carriers.form.address.postalCode')?.vm.$emit('update:modelValue', '86000')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
// En dégradé, la Ville devient un MalioInputText (plus de MalioSelect ville).
const citySelect = wrapper.findAllComponents(MalioSelectStub).find(c => c.props('label') === 'transport.carriers.form.address.city')
expect(citySelect).toBeUndefined()
expect(inputTextByLabel(wrapper, 'transport.carriers.form.address.city')).toBeDefined()
})
it('autocomplétion adresse : pas d\'appel BAN sous 3 caractères', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('autocomplétion adresse : émet « degraded » une seule fois malgré plusieurs erreurs', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue de la paix')
await flushPromises()
auto.vm.$emit('search', 'rue de la paixx')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
})
@@ -19,13 +19,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockDelete = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
delete: mockDelete,
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
@@ -36,6 +37,9 @@ vi.stubGlobal('useToast', () => ({
}))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
describe('useCarrierForm', () => {
beforeEach(() => {
@@ -107,6 +111,8 @@ describe('useCarrierForm', () => {
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
// Annule le défaut « BENNE » pour vérifier la garde « contenant obligatoire ».
form.main.containerType = null
const created = await form.submitMain()
@@ -319,16 +325,18 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
})
})
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
it('RG-4.03 affrété, indexation/volume vides : omis du payload (containerType garde son défaut BENNE)', () => {
const form = useCarrierForm()
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
// indexation / volume vides → omis (422 NotBlank back) ; containerType défaut « BENNE » envoyé.
expect(form.buildMainPayload()).toEqual({
name: 'Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
containerType: 'BENNE',
})
})
@@ -431,4 +439,526 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
certificationType: 'QUALIMAT',
})
})
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
const form = useCarrierForm()
form.main.name = 'Acme'
await form.applyQualimatSelection(QUALIMAT_ROW)
expect(form.addresses.value).toHaveLength(1)
expect(form.addresses.value[0]).toEqual({
id: null,
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '1 rue du Port',
streetComplement: null,
})
})
})
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
})
/** Transporteur créé, onglet Adresses accessible. */
function createdForm() {
const form = useCarrierForm()
form.carrierId.value = 7
return form
}
/** Remplit un bloc adresse complet (CP + ville + rue). */
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
const a = form.addresses.value[index]
if (a) {
a.postalCode = '86100'
a.city = 'Châtellerault'
a.street = '1 rue du Test'
}
}
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
const form = createdForm()
expect(form.canAddAddress.value).toBe(false)
form.addAddress()
expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète
fillAddress(form)
expect(form.canAddAddress.value).toBe(true)
form.addAddress()
expect(form.addresses.value).toHaveLength(2)
})
it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ id: 88 })
const form = createdForm()
fillAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers/7/addresses')
expect(body).toEqual({
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
streetComplement: null,
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.addresses.value[0]?.id).toBe(88)
expect(form.isValidated('addresses')).toBe(true)
})
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillAddress(form)
const first = form.addresses.value[0]
if (first) first.id = 88
await form.submitAddresses(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
})
it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire pour un transporteur affrété.' }] },
},
})
const form = createdForm()
fillAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(false)
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
expect(form.isValidated('addresses')).toBe(false)
})
it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => {
mockDelete.mockResolvedValueOnce({})
const form = createdForm()
fillAddress(form)
const first = form.addresses.value[0]
if (first) first.id = 88
form.addAddress()
fillAddress(form, 1)
await form.removeAddress(0)
expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false })
expect(form.addresses.value).toHaveLength(1)
})
})
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phonePrimary: '0102030405' })).toBe(false)
// phoneSecondary seul ne compte pas (aligné M1/M2/M3).
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
})
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
})
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
expect(body.phones).toEqual(['0102030405'])
})
it('buildCarrierContactPayload : phones = 2 numéros si secondaire révélé', () => {
const body = buildCarrierContactPayload({
...emptyCarrierContact(),
phonePrimary: '0102030405',
phoneSecondary: '0605040302',
hasSecondaryPhone: true,
})
expect(body.phones).toEqual(['0102030405', '0605040302'])
})
it('buildCarrierContactPayload : 2e numéro ignoré tant que non révélé', () => {
const body = buildCarrierContactPayload({
...emptyCarrierContact(),
phonePrimary: '0102030405',
phoneSecondary: '0605040302',
hasSecondaryPhone: false,
})
expect(body.phones).toEqual(['0102030405'])
})
})
describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
})
/** Transporteur créé, onglet Contacts accessible. */
function createdForm() {
const form = useCarrierForm()
form.carrierId.value = 7
return form
}
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
const form = createdForm()
expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc n'est pas nommé.
form.addContact()
expect(form.contacts.value).toHaveLength(1)
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
const first = form.contacts.value[0]
if (first) first.jobTitle = 'Acheteur'
expect(form.canAddContact.value).toBe(false)
form.addContact()
expect(form.contacts.value).toHaveLength(1)
// Un nom (ou prénom) débloque l'ajout.
if (first) first.lastName = 'Doe'
expect(form.canAddContact.value).toBe(true)
form.addContact()
expect(form.contacts.value).toHaveLength(2)
})
it('submitContacts : POST des nouveaux contacts (phones tableau), capture id, finalise', async () => {
mockPost.mockResolvedValueOnce({ id: 55 })
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.firstName = 'Jean'; c.phonePrimary = '0102030405' }
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/carriers/7/contacts')
expect(body).toMatchObject({ firstName: 'Jean', phones: ['0102030405'] })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.contacts.value[0]?.id).toBe(55)
expect(form.isValidated('contacts')).toBe(true)
})
it('submitContacts : PATCH des contacts existants sur /carrier_contacts/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.id = 55; c.lastName = 'Doe' }
await form.submitContacts(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
})
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
},
})
const form = createdForm()
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false)
expect(mockPost).toHaveBeenCalledTimes(1)
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
expect(form.isValidated('contacts')).toBe(false)
})
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
mockDelete.mockResolvedValueOnce({})
const form = createdForm()
const c = form.contacts.value[0]
if (c) { c.id = 90; c.lastName = 'Doe' }
form.addContact()
const c2 = form.contacts.value[1]
if (c2) c2.firstName = 'Jean'
await form.removeContact(0)
expect(mockDelete).toHaveBeenCalledWith('/carrier_contacts/90', {}, { toast: false })
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)
})
})
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),
}
}
@@ -1,12 +1,30 @@
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyCarrierAddress,
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 {
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). */
@@ -52,6 +70,7 @@ export function useCarrierForm() {
const carrierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
@@ -245,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
@@ -255,6 +336,324 @@ export function useCarrierForm() {
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
}
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
})
}
/**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
}
else {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const addressErrors = ref<Record<string, string>[]>([])
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isCarrierAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) {
addresses.value.push(emptyCarrierAddress())
}
}
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
async function removeAddress(index: number): Promise<void> {
await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/carrier_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierAddress,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Adresses : POST des nouvelles adresses sur
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
* validé (avancé/terminé).
*/
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildCarrierAddressPayload(address)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
}
},
onError,
)
if (hasError) {
return false
}
completeTab('addresses')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
// suffisent pas à ajouter un nouveau bloc).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isCarrierContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) {
contacts.value.push(emptyCarrierContact())
}
}
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/carrier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Contacts : POST des nouveaux contacts sur
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildCarrierContactPayload(contact)
if (contact.id === null) {
const created = await api.post<{ id: number }>(
`/carriers/${carrierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
}
else {
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
}
},
onError,
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
)
if (hasError) {
return false
}
completeTab('contacts')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── 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 /
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
@@ -290,6 +689,16 @@ export function useCarrierForm() {
city: row.city ?? '',
street: row.address ?? '',
}
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
// QUALIMAT survit, les champs restent éditables — § 2.5).
addresses.value = [{
id: null,
country: 'France',
postalCode: row.postalCode || null,
city: row.city || null,
street: row.address || null,
streetComplement: null,
}]
return true
}
@@ -321,6 +730,7 @@ export function useCarrierForm() {
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// affichage conditionnel
isLiot,
@@ -336,12 +746,36 @@ export function useCarrierForm() {
validated,
editMode,
isValidated,
// adresses
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// prix
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
// actions
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
prefillFrom,
patchCarrier,
applyQualimatSelection,
completeTab,
submitRows,
}
}
@@ -0,0 +1,384 @@
<template>
<div>
<!-- En-tête : retour consultation + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.edit.title') }}</h1>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.edit.notFound') }}</p>
<template v-else>
<!-- Formulaire principal (éditable, PATCH partiel) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
:label="t('transport.carriers.form.main.name')"
:required="true"
:error="mainErrors.errors.name"
/>
<MalioInputText
v-if="isLiot"
v-model="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
:hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:error="mainErrors.errors.liotPlates"
/>
<template v-if="!isLiot">
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:readonly="certificationReadonly"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/>
<MalioInputUpload
v-if="showDischarge"
:label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*"
:required="true"
:clearable="true"
:error="mainErrors.errors.dischargeDocument"
@clear="main.dischargeDocumentIri = null"
/>
<div v-else class="hidden xl:block"></div>
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-edit-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
<template v-if="showCharteredFields">
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.address.add')" :disabled="!canAddAddress" @click="addAddress" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
</div>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.contact.add')" :disabled="!canAddContact" @click="addContact" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitContacts" />
</div>
</div>
</template>
<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
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
/>
<div 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.edit.save')" :disabled="tabSubmitting" @click="onSubmitPrices" />
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation de suppression de bloc. -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton variant="secondary" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.cancel')" @click="deleteConfirm.open = false" />
<MalioButton variant="danger" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.confirm')" @click="runDeleteConfirm" />
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.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 { useCarrier } from '~/modules/transport/composables/useCarrier'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
useHead({ title: t('transport.carriers.edit.title') })
// Gating route : l'édition est réservée à `manage` ; sinon retour consultation.
if (!can('transport.carriers.manage')) {
await navigateTo(`/carriers/${carrierId}`)
}
const { carrier, loading, error, load } = useCarrier(carrierId)
const {
main,
mainSubmitting,
tabSubmitting,
mainErrors,
isLiot,
certificationReadonly,
showCharteredFields,
showDischarge,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
updateMain,
prefillFrom,
} = useCarrierForm()
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() => {
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
if (main.certificationType === 'QUALIMAT') codes.unshift('QUALIMAT')
return codes.map(code => ({ value: code, label: t(`transport.carriers.certification.${code}`) }))
})
const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const activeTab = ref('addresses')
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// ── Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix) ───
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
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 = []
}
}
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>('/countries', { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France') ? list : [{ value: 'France', label: 'France' }, ...list]
}
catch { /* fallback France */ }
}
// ── Chargement + préremplissage ──────────────────────────────────────────────
onMounted(async () => {
await load()
if (carrier.value) {
prefillFrom(carrier.value)
}
loadCountries().catch(() => {})
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']))
})
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
const indexationKey = ref(0)
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
function onIndexationInput(value: string): void {
const clamped = clampPercent(value)
main.indexationRate = clamped
if (clamped !== value) {
indexationKey.value += 1
}
}
function goBack(): void {
router.push(`/carriers/${carrierId}`)
}
/** PATCH du formulaire principal (pas de re-POST). */
async function onUpdateMain(): Promise<void> {
const ok = await updateMain()
if (ok) {
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
}
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.contactSaved') })
}
async function onSubmitPrices(): Promise<void> {
const ok = await submitPrices(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.priceSaved') })
}
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveAddress(index: number): void {
deleteConfirm.action = () => { void removeAddress(index) }
deleteConfirm.open = true
}
function askRemoveContact(index: number): void {
deleteConfirm.action = () => { void removeContact(index) }
deleteConfirm.open = true
}
function askRemovePrice(index: number): void {
deleteConfirm.action = () => { void removePrice(index) }
deleteConfirm.open = true
}
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
function onAddressDegraded(): void {
toast.warning({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.form.address.degraded') })
}
</script>
@@ -0,0 +1,497 @@
<template>
<div>
<!-- En-tête : retour répertoire + nom + actions. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('transport.carriers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('transport.carriers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('transport.carriers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
<template v-else-if="carrier">
<!-- Bloc principal (lecture seule) même disposition que l'ajout -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
<!-- Cas LIOT : seul le champ immatriculations. -->
<MalioInputText
v-if="isLiot"
:model-value="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
readonly
/>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot">
<MalioInputText
:model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')"
readonly
/>
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
<MalioInputText
v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')"
readonly
/>
<div v-else class="hidden xl:block"></div>
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
readonly
:reserve-message-space="false"
/>
</div>
<!-- Champs d'affrètement (ligne 2) si affrété. -->
<template v-if="main.isChartered">
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
readonly
group-class="mt-0"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
readonly
group-class="mt-0"
/>
</div>
</div>
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
</template>
</template>
</div>
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptionsFor(address.country)"
readonly
/>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<!-- Police / bordures / radius alignés sur MalioDataTable (header
16px, corps 14px). 1re colonne « Contenant » : libellé du
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
épais entre les deux groupes. -->
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
<!-- Répartition (table-fixed) : « Contenant » étroite ; Transporteurs
et Adresse livraisons larges ; Forfait / Tonne / Indexation / État
réduits. -->
<colgroup>
<col class="w-[110px]" />
<col class="w-[20%]" />
<col class="w-[11%]" />
<col class="w-[24%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
</colgroup>
<thead>
<tr>
<th class="border-b border-r border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
</tr>
</thead>
<tbody>
<template v-for="(group, gi) in priceGroups" :key="group.label">
<tr
v-for="(row, i) in group.rows"
:key="`${gi}-${i}`"
>
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
séparateur épais en bas entre les groupes (sauf dernier). -->
<td
v-if="i === 0"
:rowspan="group.rows.length"
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
:class="groupBorder(gi)"
>
{{ group.label }}
</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
</tr>
</template>
<tr v-if="!hasPrices">
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
{{ t('transport.carriers.consultation.price.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="hasPrices" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.consultation.price.export')"
:disabled="exporting"
@click="exportPrices"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
<p>{{ confirmArchive.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="confirmArchive.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="confirmArchive.confirmLabel"
@click="runToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import {
canEditCarrier,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
showArchiveAction,
showRestoreAction,
type CarrierPriceRead,
} from '~/modules/transport/utils/forms/carrierMappers'
import { extractApiErrorMessage } from '~/shared/utils/api'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
const isArchived = computed(() => carrier.value?.isArchived ?? false)
const canEdit = computed(() => canEditCarrier(can))
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
useHead({ title: t('transport.carriers.consultation.title') })
// ── Bloc principal mappé (lecture seule) ─────────────────────────────────────
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
const certificationLabel = computed(() => main.value.certificationType
? t(`transport.carriers.certification.${main.value.certificationType}`)
: '')
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
const dischargeLabel = computed(() => {
const doc = carrier.value?.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
return String(meta.originalFilename ?? meta.name ?? '')
}
return ''
})
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
const activeTab = ref('addresses')
const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Au moins un bloc affiché même sans donnée (bloc vide en lecture seule).
const addresses = computed(() => {
const list = (carrier.value?.addresses ?? []).map(mapAddressToDraft)
return list.length > 0 ? list : [mapAddressToDraft({ id: 0, '@id': '' })]
})
const contacts = computed(() => {
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
})
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : []
}
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
interface PriceRowView {
apro: string
delivery: string
forfait: string
tonne: string
indexation: string
state: string
}
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
interface PriceGroupView {
label: string
rows: PriceRowView[]
}
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
function formatAmount(value: string | null | undefined): string {
if (!value) {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return ''
}
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
/**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Adresse sites » = le site (Châtellerault / Saint-Jean / Pommevic…) ;
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
*/
function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT'
return {
apro: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.deliverySite),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
// CarrierPrice n'a pas de taux d'indexation propre → on affiche celui du
// transporteur (formulaire principal). À faire évoluer si un taux par prix
// est requis (gap back).
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
}
}
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
function stateSuffix(state: string): string {
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
return map[state] ?? ''
}
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
// par groupe (rowspan) à gauche, conformément à la maquette.
const priceGroups = computed<PriceGroupView[]>(() => {
const list = carrier.value?.prices ?? []
return PRICE_GROUP_ORDER
.map(container => ({
label: t(`transport.carriers.containerType.${container}`),
rows: list.filter(p => p.containerType === container).map(toPriceRow),
}))
.filter(group => group.rows.length > 0)
})
const hasPrices = computed(() => priceGroups.value.length > 0)
/**
* Bordure basse d'une cellule de données :
* - ligne interne d'un groupe → fine grise ;
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas).
*/
function dataBorder(group: PriceGroupView, i: number, gi: number): string {
const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1
if (!isLastRow) {
return 'border-b border-m-muted/30'
}
return isLastGroup ? '' : 'border-b-2 border-black'
}
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
function groupBorder(gi: number): string {
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
}
// ── Export XLSX des prix ─────────────────────────────────────────────────────
const exporting = ref(false)
async function exportPrices(): Promise<void> {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
}
catch {
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
}
finally {
exporting.value = false
}
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
// ── Navigation / archivage ───────────────────────────────────────────────────
function goBack(): void {
router.push('/carriers')
}
function goEdit(): void {
router.push(`/carriers/${carrierId}/edit`)
}
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.message = archiving
? t('transport.carriers.consultation.confirmArchive.message')
: t('transport.carriers.consultation.confirmRestore.message')
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.open = true
}
async function runToggleArchive(): Promise<void> {
const archiving = !isArchived.value
confirmArchive.open = false
try {
await (archiving ? archive() : restore())
toast.success({
title: archiving
? t('transport.carriers.toast.archiveSuccess')
: t('transport.carriers.toast.restoreSuccess'),
})
}
catch (err) {
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
// propagé exprès par useCarrier ; fallback générique sinon.
const data = (err as { response?: { _data?: unknown } })?.response?._data
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage(data) || undefined,
})
}
}
onMounted(load)
</script>
+376 -32
View File
@@ -86,32 +86,55 @@
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
naturellement en colonne 1 de la ligne 2. -->
<template v-if="showCharteredFields">
<MalioInputNumber
v-model="main.indexationRate"
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
:key force le ré-affichage du champ contrôlé quand on plafonne
(sinon le modelValue inchangé n'est pas re-synchronisé par Vue). -->
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
<MalioSelect
:model-value="main.containerType"
:options="containerOptions"
:label="t('transport.carriers.form.main.containerType')"
empty-option-label=""
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.containerType"
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<MalioInputNumber
v-model="main.volumeM3"
<!-- Volume m³ : champ texte restreint aux nombres à décimales (point). -->
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
@@ -135,7 +158,10 @@
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
<template #qualimat>
<div class="mt-12 flex flex-col gap-6">
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
<MalioDataTable
class="qualimat-table"
table-class="table-fixed"
:columns="qualimatColumns"
:items="qualimatRows"
:total-items="qualimatTotalDisplay"
@@ -170,7 +196,113 @@
</div>
</template>
<!-- Adresses / Contacts / Prix : contenu aux tickets suivants. -->
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="isQualimat || isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
(adresse copiée et persistée automatiquement). -->
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('transport.carriers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
max 2 téléphones). Erreurs 422 par ligne. -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('transport.carriers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('transport.carriers.form.submit')"
:disabled="tabSubmitting || carrierId === null"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- 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
v-for="key in placeholderTabs"
:key="key"
@@ -203,14 +335,42 @@
/>
</template>
</MalioModal>
<!-- Modal de confirmation de suppression (bloc adresse). -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="deleteConfirm.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.confirm')"
@click="runDeleteConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { debounce } from '~/shared/utils/debounce'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.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 { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
interface SelectOption {
value: string
@@ -218,6 +378,7 @@ interface SelectOption {
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
@@ -232,16 +393,38 @@ if (!can('transport.carriers.manage')) {
const {
main,
carrierId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
isLiot,
isQualimat,
certificationReadonly,
showCharteredFields,
showDischarge,
tabKeys,
activeTab,
unlockedIndex,
isValidated,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
submitMain,
applyQualimatSelection,
} = useCarrierForm()
@@ -308,22 +491,13 @@ const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
? t('transport.carriers.form.qualimat.empty')
: t('transport.carriers.form.qualimat.searchHint'))
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
const containerOptions = computed<SelectOption[]>(() =>
CONTAINER_TYPES.map(code => ({
value: code,
label: t(`transport.carriers.containerType.${code}`),
})),
)
// Icone (Iconify) affichee dans chaque onglet, par cle.
const TAB_ICONS: Record<string, string> = {
qualimat: 'mdi:truck-check-outline',
qualimat: 'mdi:truck-fast-outline',
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:currency-eur',
prices: 'mdi:payment',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
@@ -335,8 +509,146 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
disabled: index > unlockedIndex.value,
})))
// Onglets dont le contenu arrive aux tickets suivants (tout sauf Qualimat).
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
const placeholderTabs = computed(() => tabKeys.value.filter(
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) ────────────────────────────────────────────────
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
// rester preselectionnable par defaut sur chaque adresse (RG-4.05).
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
/** Charge le referentiel pays (/api/countries) ; conserve France par defaut si echec. */
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>(
'/countries',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
}
catch {
// Reste sur le fallback France (non bloquant).
}
}
onMounted(() => {
loadCountries().catch(() => {})
loadPriceReferentials()
})
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) {
return
}
addressDegradedNotified.value = true
toast.warning({
title: t('transport.carriers.toast.error'),
message: t('transport.carriers.form.address.degraded'),
})
}
/** Message d'erreur affichable (toast) extrait d'une erreur API — jamais undefined. */
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
}
// Modal de confirmation de suppression (générique : bloc adresse OU contact).
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveAddress(index: number): void {
deleteConfirm.action = () => { void removeAddress(index) }
deleteConfirm.open = true
}
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('transport.carriers.toast.contactSaved') })
}
}
function askRemoveContact(index: number): void {
deleteConfirm.action = () => { void removeContact(index) }
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 {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
const confirmOpen = ref(false)
@@ -412,13 +724,45 @@ async function confirmIntegrate(): Promise<void> {
}
}
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
const indexationKey = ref(0)
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
function onIndexationInput(value: string): void {
const clamped = clampPercent(value)
main.indexationRate = clamped
if (clamped !== value) {
indexationKey.value += 1
}
}
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
function goBack(): void {
router.push('/carriers')
}
/** Valide le formulaire principal (POST /carriers ; bascule geree par le composable). */
/**
* Valide le formulaire principal (POST /carriers ; bascule geree par le composable).
* RG-4.07 : pour un transporteur QUALIMAT, l'adresse copiee est persistee
* automatiquement (pas de bouton Valider dans l'onglet Adresses).
*/
async function onSubmitMain(): Promise<void> {
await submitMain()
const ok = await submitMain()
if (ok && isQualimat.value) {
await submitAddresses(error => toast.error({
title: t('transport.carriers.toast.error'),
message: apiErrorMessage(error),
}))
}
}
</script>
<style scoped>
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
.qualimat-table :deep(th:first-child),
.qualimat-table :deep(td:first-child) {
width: 56px;
}
</style>
+111 -1
View File
@@ -40,7 +40,8 @@ export function emptyCarrierMain(): CarrierMainDraft {
certificationType: null,
isChartered: false,
indexationRate: '',
containerType: null,
// Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
containerType: 'BENNE',
volumeM3: '',
liotPlates: '',
dischargeDocumentIri: null,
@@ -65,6 +66,115 @@ export function emptyCarrierAddressCopy(): CarrierAddressCopy {
return { country: 'France', postalCode: '', city: '', street: '' }
}
/**
* Brouillon d'un bloc Adresse (onglet Adresses, ERP-167) — sous-ressource
* `CarrierAddress` (groupe `carrier:write:addresses`). Version SIMPLIFIÉE de
* l'adresse fournisseur (M2) / prestataire (M3) : pas de sites / catégories /
* contacts ni type d'adresse (les sites du M4 vivent dans l'onglet Prix).
*/
export interface CarrierAddressFormDraft {
/** Id serveur une fois l'adresse créée (null tant que non persistée). */
id: number | null
/** Pays (chaîne libre, défaut « France »). */
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
}
/** Brouillon d'adresse vide (pays France par défaut, RG-4.05). */
export function emptyCarrierAddress(): CarrierAddressFormDraft {
return {
id: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
}
}
/**
* Brouillon d'un bloc Contact (onglet Contacts, ERP-168) — sous-ressource
* `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis
* en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du
* tableau `phones` (max 2 — RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e
* numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis
* l'adresse au M4 (≠ M3).
*/
export interface CarrierContactFormDraft {
/** Id serveur une fois le contact créé (null tant que non persisté). */
id: number | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */
hasSecondaryPhone: boolean
}
/** Brouillon de contact vide (état initial d'un bloc Contact). */
export function emptyCarrierContact(): CarrierContactFormDraft {
return {
id: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/**
* 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
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest'
import {
canEditCarrier,
iriOf,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
mapPriceToDraft,
showArchiveAction,
showRestoreAction,
type CarrierDetail,
} from '../carrierMappers'
/**
* Tests des mappers détail → brouillons (M4 Transport, ERP-170) : peuplent les écrans
* Consultation / Modification depuis la SEULE réponse `GET /api/carriers/{id}`, et
* helpers de visibilité des boutons (Modifier / Archiver / Restaurer) selon la permission.
*/
describe('carrierMappers', () => {
it('iriOf : objet embarqué, IRI nu, ou null', () => {
expect(iriOf({ '@id': '/api/clients/3' })).toBe('/api/clients/3')
expect(iriOf('/api/sites/1')).toBe('/api/sites/1')
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
it('labelOfRelation : name (site) à défaut adresse condensée', () => {
expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault')
expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers')
expect(labelOfRelation('/api/sites/1')).toBe('')
expect(labelOfRelation(null)).toBe('')
})
it('mapMainToDraft : scalaires + IRI décharge / qualimat', () => {
const detail: CarrierDetail = {
'@id': '/api/carriers/7',
id: 7,
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
isChartered: true,
indexationRate: '5.00',
containerType: 'BENNE',
volumeM3: '30.00',
dischargeDocument: { '@id': '/api/uploaded_documents/4' },
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
}
expect(mapMainToDraft(detail)).toEqual({
name: 'TRANSPORTS ACME',
certificationType: 'QUALIMAT',
isChartered: true,
indexationRate: '5.00',
containerType: 'BENNE',
volumeM3: '30.00',
liotPlates: '',
dischargeDocumentIri: '/api/uploaded_documents/4',
qualimatCarrierIri: '/api/qualimat_carriers/42',
})
})
it('mapAddressToDraft : pays par défaut France si absent', () => {
expect(mapAddressToDraft({ '@id': '/api/carrier_addresses/3', id: 3, postalCode: '86000', city: 'Poitiers' }))
.toEqual({ id: 3, country: 'France', postalCode: '86000', city: 'Poitiers', street: null, streetComplement: null })
})
it('mapContactToDraft : hasSecondaryPhone vrai seulement si 2e numéro présent', () => {
const one = mapContactToDraft({ '@id': '/api/carrier_contacts/1', id: 1, firstName: 'Jean', phonePrimary: '0102030405' })
expect(one.hasSecondaryPhone).toBe(false)
expect(one.firstName).toBe('Jean')
const two = mapContactToDraft({ '@id': '/api/carrier_contacts/2', id: 2, phonePrimary: '0102030405', phoneSecondary: '0605040302' })
expect(two.hasSecondaryPhone).toBe(true)
expect(two.phoneSecondary).toBeTruthy()
})
it('mapPriceToDraft : direction + IRIs des relations de branche', () => {
const draft = mapPriceToDraft({
'@id': '/api/carrier_prices/5',
id: 5,
direction: 'CLIENT',
client: { '@id': '/api/clients/3' },
clientDeliveryAddress: { '@id': '/api/client_addresses/8' },
departureSite: '/api/sites/1',
containerType: 'BENNE',
pricingUnit: 'FORFAIT',
price: '120.00',
priceState: 'EN_COURS',
})
expect(draft).toMatchObject({
id: 5,
direction: 'CLIENT',
clientIri: '/api/clients/3',
clientDeliveryAddressIri: '/api/client_addresses/8',
departureSiteIri: '/api/sites/1',
supplierIri: null,
containerType: 'BENNE',
pricingUnit: 'FORFAIT',
price: '120.00',
priceState: 'EN_COURS',
})
})
it('visibilité des boutons selon la permission', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
// Modifier : seulement avec manage.
expect(canEditCarrier(can(['transport.carriers.manage']))).toBe(true)
expect(canEditCarrier(can(['transport.carriers.view']))).toBe(false)
// Archiver : permission archive ET actif ; Restaurer : archive ET archivé.
const withArchive = can(['transport.carriers.archive'])
const noArchive = can(['transport.carriers.manage'])
expect(showArchiveAction(withArchive, false)).toBe(true)
expect(showArchiveAction(withArchive, true)).toBe(false)
expect(showRestoreAction(withArchive, true)).toBe(true)
expect(showRestoreAction(withArchive, false)).toBe(false)
expect(showArchiveAction(noArchive, false)).toBe(false)
expect(showRestoreAction(noArchive, true)).toBe(false)
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { clampPercent, sanitizeDecimal } from '../numberInput'
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
expect(sanitizeDecimal('30')).toBe('30')
expect(sanitizeDecimal('30.5')).toBe('30.5')
expect(sanitizeDecimal('30,5 kg')).toBe('30.5') // virgule FR → point ; espace + lettres retirés
expect(sanitizeDecimal('1.2.3')).toBe('1.23') // un seul point conservé
expect(sanitizeDecimal('abc12.3x')).toBe('12.3')
expect(sanitizeDecimal('')).toBe('')
})
it('clampPercent : plafonne à 100, laisse le reste tel quel', () => {
expect(clampPercent('50')).toBe('50')
expect(clampPercent('100')).toBe('100')
expect(clampPercent('150')).toBe('100')
expect(clampPercent('100.01')).toBe('100')
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
expect(clampPercent('')).toBe('')
})
})
@@ -0,0 +1,35 @@
/**
* Helpers purs de l'onglet Adresse transporteur (M4 Transport, ERP-167) — miroir
* SIMPLIFIÉ de `providerAddress` (M3) / `SupplierAddressBlock` (M2), sans sites /
* catégories / contacts (les sites du M4 vivent dans l'onglet Prix). Testables
* sans Vue.
*/
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
/**
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
* (donc on autorise l'ajout d'un nouveau bloc) dès qu'elle porte un code postal,
* une ville ET une rue. Les RG conditionnelles (obligatoire si affrété) restent
* validées par le back (422 inline) — ce gate empêche seulement d'empiler des
* blocs vides.
*/
export function isCarrierAddressValid(address: CarrierAddressFormDraft): boolean {
return Boolean(address.postalCode?.trim() && address.city?.trim() && address.street?.trim())
}
/**
* Payload de la sous-ressource addresses (groupe `carrier:write:addresses`). Les
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
* renvoie une 422 par champ).
*/
export function buildCarrierAddressPayload(address: CarrierAddressFormDraft): Record<string, unknown> {
return {
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
}
}
@@ -0,0 +1,61 @@
/**
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) — ALIGNÉ
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
* Testables sans Vue ni API.
*/
import type { CarrierContactFormDraft } 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() !== ''
}
/**
* Un bloc Contact est VIDE tant qu'aucun champ comptant pour la validité n'est
* rempli — prénom / nom / fonction / téléphone principal / email. `phoneSecondary`
* est EXCLU (aligné M1/M2/M3 : un bloc ne portant qu'un 2e numéro reste vide). Sert
* le filtrage des amorces vides à la soumission.
*/
export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean {
return ![
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.email,
].some(isFilled)
}
/**
* Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom — aligné
* sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
* / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
*/
export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
* regroupés dans le tableau `phones` (numéros non vides, max 2 — RG-4.08) ; le 2e
* numéro n'est inclus que s'il a été révélé (`hasSecondaryPhone`).
*/
export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record<string, unknown> {
const phones = [
contact.phonePrimary,
contact.hasSecondaryPhone ? contact.phoneSecondary : null,
].filter((phone): phone is string => isFilled(phone))
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
email: contact.email || null,
phones,
}
}
@@ -0,0 +1,190 @@
/**
* Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170) —
* miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}`
* (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` +
* read-groups cross-module client/supplier/site/adresses) vers les brouillons
* « plats » partagés avec les blocs Adresse / Contact / Prix.
*
* Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs
* nuls peuvent être OMIS (skip_null_values) → toujours lire avec `?? null`.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
CarrierAddressFormDraft,
CarrierContactFormDraft,
CarrierMainDraft,
CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm'
/** Référence Hydra embarquée minimale (@id toujours présent). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Adresse embarquée (groupe carrier:item:read). */
export interface CarrierAddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
}
/** Contact embarqué (groupe carrier:item:read). */
export interface CarrierContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Prix embarqué (groupe carrier:item:read + relations cross-module). */
export interface CarrierPriceRead extends HydraRef {
id: number
direction?: string | null
client?: Relation
clientDeliveryAddress?: Relation
departureSite?: Relation
supplier?: Relation
supplierSupplyAddress?: Relation
deliverySite?: Relation
containerType?: string | null
pricingUnit?: string | null
price?: string | null
priceState?: string | null
}
/**
* Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels :
* skip_null_values peut omettre n'importe quelle clé.
*/
export interface CarrierDetail extends HydraRef {
id: number
name?: string | null
certificationType?: string | null
isChartered?: boolean
indexationRate?: string | null
containerType?: string | null
volumeM3?: string | null
liotPlates?: string | null
dischargeDocument?: Relation
qualimatCarrier?: Relation
isArchived?: boolean
addresses?: CarrierAddressRead[]
contacts?: CarrierContactRead[]
prices?: CarrierPriceRead[]
}
/** Extrait l'IRI d'une relation (objet embarqué, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/**
* Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse
* condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente.
*/
export function labelOfRelation(relation: Relation): string {
if (!relation || typeof relation === 'string') {
return ''
}
const name = relation.name as string | undefined
if (name) {
return name
}
const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean)
return parts.join(' · ')
}
/** Mappe le détail vers le brouillon du formulaire principal. */
export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft {
return {
name: detail.name ?? '',
certificationType: detail.certificationType ?? null,
isChartered: detail.isChartered ?? false,
indexationRate: detail.indexationRate ?? '',
containerType: detail.containerType ?? null,
volumeM3: detail.volumeM3 ?? '',
liotPlates: detail.liotPlates ?? '',
dischargeDocumentIri: iriOf(detail.dischargeDocument),
qualimatCarrierIri: iriOf(detail.qualimatCarrier),
}
}
/** Mappe une adresse embarquée vers un brouillon. */
export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft {
return {
id: address.id,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
}
}
/** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */
export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft {
const secondary = contact.phoneSecondary ?? null
return {
id: contact.id,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: secondary ? formatPhoneFR(secondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: secondary !== null && secondary !== '',
}
}
/** Mappe un prix embarqué vers un brouillon (relations en IRI). */
export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft {
const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR'
? price.direction
: null
return {
id: price.id,
direction,
clientIri: iriOf(price.client),
clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress),
departureSiteIri: iriOf(price.departureSite),
supplierIri: iriOf(price.supplier),
supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress),
deliverySiteIri: iriOf(price.deliverySite),
containerType: price.containerType ?? null,
pricingUnit: price.pricingUnit ?? null,
price: price.price ?? null,
priceState: price.priceState ?? null,
}
}
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
export function canEditCarrier(can: (code: string) => boolean): boolean {
return can('transport.carriers.manage')
}
/** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('transport.carriers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('transport.carriers.archive') && isArchived
}
@@ -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
}
@@ -0,0 +1,28 @@
/**
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
*/
/**
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
* « nombres avec des points » comme les autres modules). La virgule décimale FR est
* convertie en point (« 30,5 » → « 30.5 ») ; tout autre caractère est supprimé.
*/
export function sanitizeDecimal(value: string): string {
let cleaned = (value ?? '').replace(/,/g, '.').replace(/[^0-9.]/g, '')
const dot = cleaned.indexOf('.')
if (dot !== -1) {
// Conserve le 1er point, retire les suivants.
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
}
return cleaned
}
/**
* Plafonne un pourcentage à 100 (contrainte FRONT : l'indexation n'a pas de max back).
* Renvoie « 100 » si la valeur saisie dépasse 100, sinon la valeur telle quelle.
*/
export function clampPercent(value: string): string {
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
return (!Number.isNaN(n) && n > 100) ? '100' : value
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-4.08 (correctif) — aligne la regle de validite d'un contact transporteur sur
* le M1/M2/M3 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque
* parmi prenom/nom/fonction/telephone/email »). Remplace le CHECK
* chk_carrier_contact_filled par chk_carrier_contact_name et met a jour les
* commentaires de colonnes. La garde applicative (CarrierContactProcessor::validateName)
* est alignee dans le meme commit ; le catalogue ColumnCommentsCatalog aussi.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260617120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-4.08 : contact transporteur valide si prenom OU nom (alignement M1/M2/M3) — CHECK chk_carrier_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_filled');
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_filled CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
}
}
@@ -21,8 +21,8 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
*
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`.
@@ -23,21 +23,21 @@ use function is_string;
/**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
* par ce Processor.
* perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
* ce Processor ; le « max 2 telephones » reste une specificite M4.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
* (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
* persistance.
* (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
* violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
* (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
*
@@ -77,7 +77,7 @@ final class CarrierContactProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->applyPhones($data);
$this->validateAtLeastOneField($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
@@ -187,25 +187,18 @@ final class CarrierContactProcessor implements ProcessorInterface
}
/**
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
* (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
* deja ramenees a null.
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
* NOM — un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
* leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
* normalisation + mapping telephones, donc les chaines vides sont deja null.
*/
private function validateAtLeastOneField(CarrierContact $contact): void
private function validateName(CarrierContact $contact): void
{
if (
null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()
) {
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Renseignez au moins un champ pour le contact.',
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
@@ -219,8 +212,8 @@ final class CarrierContactProcessor implements ProcessorInterface
/**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
* « non rempli » meme si le client envoie une chaine vide.
* contrairement aux noms de personne). Evite de persister une chaine vide
* (« » devient null) cote fonction du contact.
*/
private function blankToNull(?string $value): ?string
{
@@ -194,7 +194,7 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
/**
* Ajoute un contact normalise au transporteur (cascade persist via
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
*/
private function addContact(
Carrier $carrier,
@@ -509,11 +509,11 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(),
'carrier_contact' => [
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
@@ -15,8 +15,8 @@ use Symfony\Component\Console\Output\NullOutput;
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
*
* Contrat verifie :
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
* - RG-4.08 : 1 seul champ rempli -> 201 ;
* - RG-4.08 : contact sans prenom ni nom -> 422 (alignement M1/M2/M3) ;
* - RG-4.08 : un nom (ou prenom) suffit -> 201 ;
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
@@ -51,7 +51,8 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
public function testEmptyContactReturns422(): void
{
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
// RG-4.08 (alignement M1/M2/M3) : sans prenom ni nom -> 422 (garde Processor,
// double du CHECK BDD chk_carrier_contact_name).
$carrier = $this->seedCarrier('Contact Vide');
$client = $this->createAdminClient();
@@ -60,13 +61,13 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
// La violation est rattachee a `firstName` (mapping inline ERP-101).
self::assertViolationOnPath($response, 'firstName');
}
public function testSingleFieldContactIsCreated(): void
{
// RG-4.08 : un seul champ suffit a valider le bloc.
// RG-4.08 : un nom (ou prenom) suffit a valider le bloc.
$carrier = $this->seedCarrier('Contact Mono');
$client = $this->createAdminClient();