Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f2aa5334b | |||
| 21b1c64a5f | |||
| d304b74289 | |||
| 80b3741f64 | |||
| c468374b16 | |||
| 7ddf495d7f | |||
| 9fcf5c24f6 | |||
| 76fb01c063 | |||
| e76bd1dd63 | |||
| 498cef8cc0 | |||
| 7668d77c78 | |||
| 1d5110d000 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.131'
|
app.version: '0.1.138'
|
||||||
|
|||||||
@@ -554,12 +554,12 @@
|
|||||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"group": "Contenant",
|
"group": "Type de transport",
|
||||||
"carrier": "Transporteurs",
|
"carrier": "Transporteurs",
|
||||||
"aproOrSite": "Adresse sites",
|
"aproOrSite": "Adresse sites",
|
||||||
"delivery": "Adresse livraisons",
|
"delivery": "Adresse livraisons",
|
||||||
"forfait": "Forfait €",
|
"forfait": "Forfait (€)",
|
||||||
"tonne": "Tonne €",
|
"tonne": "Tonne (€)",
|
||||||
"indexation": "Indexation",
|
"indexation": "Indexation",
|
||||||
"state": "État du prix",
|
"state": "État du prix",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
@@ -621,7 +621,8 @@
|
|||||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||||
"containerTypeRequired": "Le type de contenant 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é."
|
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
|
||||||
|
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||||
},
|
},
|
||||||
"address": {
|
"address": {
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
@@ -630,8 +631,6 @@
|
|||||||
"street": "Adresse",
|
"street": "Adresse",
|
||||||
"streetComplement": "Adresse complémentaire",
|
"streetComplement": "Adresse complémentaire",
|
||||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
"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."
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
|
||||||
<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)]">
|
<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). -->
|
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="model.country"
|
:model-value="model.country"
|
||||||
@@ -114,7 +105,6 @@ const props = defineProps<{
|
|||||||
modelValue: CarrierAddressFormDraft
|
modelValue: CarrierAddressFormDraft
|
||||||
/** Pays disponibles (France par defaut). */
|
/** Pays disponibles (France par defaut). */
|
||||||
countryOptions: RefOption[]
|
countryOptions: RefOption[]
|
||||||
removable?: boolean
|
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
@@ -122,7 +112,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: CarrierAddressFormDraft]
|
'update:modelValue': [value: CarrierAddressFormDraft]
|
||||||
'remove': []
|
|
||||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||||
'degraded': []
|
'degraded': []
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { debounce } from '~/shared/utils/debounce'
|
||||||
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglet « Qualimat » — saisie assistée par recherche dans le référentiel QUALIMAT
|
||||||
|
* (RG-4.01 / RG-4.04). Datatable paginé filtré par le NOM (`searchName`), sélection
|
||||||
|
* d'une ligne → modal de confirmation → `integrate`. Mutualisé entre l'écran
|
||||||
|
* d'AJOUT (ERP-166) et l'écran de MODIFICATION (ERP-172, « actualiser le
|
||||||
|
* transporteur »). La persistance (copie nom / certification / FK) est portée par
|
||||||
|
* le parent via `useCarrierForm.applyQualimatSelection`.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Terme de recherche (nom du transporteur saisi dans le formulaire principal). */
|
||||||
|
searchName: string
|
||||||
|
/** IRI QUALIMAT actuellement lié (coche le radio de la ligne correspondante). */
|
||||||
|
selectedIri: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'integrate', row: QualimatCarrierRow): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: qualimatItems,
|
||||||
|
totalItems: qualimatTotal,
|
||||||
|
currentPage: qualimatPage,
|
||||||
|
itemsPerPage: qualimatPerPage,
|
||||||
|
itemsPerPageOptions: qualimatPerPageOptions,
|
||||||
|
goToPage: qualimatGoToPage,
|
||||||
|
setItemsPerPage: qualimatSetPerPage,
|
||||||
|
setFilters: qualimatSetFilters,
|
||||||
|
} = useQualimatSearch()
|
||||||
|
|
||||||
|
// Colonnes du datatable de sélection QUALIMAT (radio / Nom / Adresse / Validité).
|
||||||
|
const qualimatColumns = [
|
||||||
|
{ key: 'select', label: '' },
|
||||||
|
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
||||||
|
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
||||||
|
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
||||||
|
// pas saisi (pas de liste complète par défaut).
|
||||||
|
const hasQualimatSearch = computed(() => props.searchName.trim() !== '')
|
||||||
|
|
||||||
|
const qualimatRows = computed(() => {
|
||||||
|
if (!hasQualimatSearch.value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return qualimatItems.value.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
iri: row['@id'],
|
||||||
|
name: row.name,
|
||||||
|
address: formatQualimatAddress(row),
|
||||||
|
validityDate: row.validityDate,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
||||||
|
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
||||||
|
? t('transport.carriers.form.qualimat.empty')
|
||||||
|
: t('transport.carriers.form.qualimat.searchHint'))
|
||||||
|
|
||||||
|
// Re-filtrage debouncé sur le nom ; aucune recherche tant que le Nom est vide.
|
||||||
|
const filterQualimatByName = debounce((term: string) => {
|
||||||
|
if (term.trim() === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void qualimatSetFilters({ search: term })
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
watch(() => props.searchName, term => filterQualimatByName(term), { immediate: true })
|
||||||
|
|
||||||
|
/** Adresse QUALIMAT condensée pour la colonne « Adresse » (voie · CP · ville). */
|
||||||
|
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
||||||
|
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RG-4.04 : un agrément est périmé si sa date de validité est < aujourd'hui. */
|
||||||
|
function isExpired(value: string): boolean {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime() < today.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format court français JJ-MM-AAAA (chaîne vide si date absente / invalide). */
|
||||||
|
function formatDateFr(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${day}-${month}-${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirmation d'intégration ───────────────────────────────────────────────
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||||
|
|
||||||
|
/** Clic sur une ligne → retrouve la ligne QUALIMAT source + ouvre la modal. */
|
||||||
|
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||||
|
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||||
|
if (row) {
|
||||||
|
pendingRow.value = row
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirme l'intégration : délègue la persistance au parent via `integrate`. */
|
||||||
|
function confirmIntegrate(): void {
|
||||||
|
const row = pendingRow.value
|
||||||
|
confirmOpen.value = false
|
||||||
|
if (row !== null) {
|
||||||
|
emit('integrate', row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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"
|
||||||
|
:page="qualimatPage"
|
||||||
|
:per-page="qualimatPerPage"
|
||||||
|
:per-page-options="qualimatPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="qualimatEmptyMessage"
|
||||||
|
@row-click="onQualimatRowClick"
|
||||||
|
@update:page="qualimatGoToPage"
|
||||||
|
@update:per-page="qualimatSetPerPage"
|
||||||
|
>
|
||||||
|
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||||
|
<template #cell-select="{ item }">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="selectedIri"
|
||||||
|
name="qualimat-row"
|
||||||
|
:value="item.iri"
|
||||||
|
group-class="mt-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
||||||
|
<template #cell-validityDate="{ item }">
|
||||||
|
<span
|
||||||
|
v-if="item.validityDate"
|
||||||
|
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||||
|
>
|
||||||
|
{{ formatDateFr(item.validityDate as string) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation d'intégration QUALIMAT. -->
|
||||||
|
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
||||||
|
@click="confirmOpen = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
||||||
|
@click="confirmIntegrate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -350,6 +350,52 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
|||||||
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
|
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
|
||||||
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('RG-4.02 upload différé : selectDischarge ne POST pas ; submitMain upload PUIS crée', async () => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
// 1er POST = /uploaded_documents (renvoie l'IRI) ; 2e = /carriers (création).
|
||||||
|
mockPost
|
||||||
|
.mockResolvedValueOnce({ '@id': '/api/uploaded_documents/7' })
|
||||||
|
.mockResolvedValueOnce({ id: 12, name: 'ACME', certificationType: 'AUTRE' })
|
||||||
|
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.main.name = 'Acme'
|
||||||
|
form.main.certificationType = 'AUTRE'
|
||||||
|
|
||||||
|
// Sélection du fichier : aucun appel réseau (upload différé à l'enregistrement).
|
||||||
|
form.selectDischarge(new File(['x'], 'decharge.pdf', { type: 'application/pdf' }))
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
// La validation est satisfaite par le fichier en attente (pas encore d'IRI).
|
||||||
|
expect(form.mainErrors.errors.dischargeDocument).toBeUndefined()
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
expect(created).toBe(true)
|
||||||
|
|
||||||
|
// 1er appel : upload multipart ; 2e : création carrier avec l'IRI résolu.
|
||||||
|
expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents')
|
||||||
|
expect(mockPost.mock.calls[1][0]).toBe('/carriers')
|
||||||
|
expect(mockPost.mock.calls[1][1]).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-4.02 upload différé : un 422 MIME bloque la création (message inline, pas de POST /carriers)', async () => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
// Le POST /uploaded_documents échoue (MIME hors whitelist) → 422.
|
||||||
|
mockPost.mockRejectedValueOnce(Object.assign(new Error('422'), {
|
||||||
|
data: { 'hydra:description': 'Type de fichier non autorisé.' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.main.name = 'Acme'
|
||||||
|
form.main.certificationType = 'AUTRE'
|
||||||
|
form.selectDischarge(new File(['x'], 'malware.exe', { type: 'application/x-msdownload' }))
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
expect(created).toBe(false)
|
||||||
|
// Message back affiché inline sous le champ ; aucune création de carrier.
|
||||||
|
expect(form.mainErrors.errors.dischargeDocument).toBe('Type de fichier non autorisé.')
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
||||||
@@ -440,14 +486,13 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
|
it('applyQualimatSelection pré-remplit l\'adresse unique à la création (RG-4.05)', async () => {
|
||||||
const form = useCarrierForm()
|
const form = useCarrierForm()
|
||||||
form.main.name = 'Acme'
|
form.main.name = 'Acme'
|
||||||
|
|
||||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||||
|
|
||||||
expect(form.addresses.value).toHaveLength(1)
|
expect(form.address.value).toEqual({
|
||||||
expect(form.addresses.value[0]).toEqual({
|
|
||||||
id: null,
|
id: null,
|
||||||
country: 'France',
|
country: 'France',
|
||||||
postalCode: '86000',
|
postalCode: '86000',
|
||||||
@@ -458,53 +503,38 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPost.mockReset()
|
mockPost.mockReset()
|
||||||
mockPatch.mockReset()
|
mockPatch.mockReset()
|
||||||
mockDelete.mockReset()
|
mockDelete.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Transporteur créé, onglet Adresses accessible. */
|
/** Transporteur créé, onglet Adresse accessible. */
|
||||||
function createdForm() {
|
function createdForm() {
|
||||||
const form = useCarrierForm()
|
const form = useCarrierForm()
|
||||||
form.carrierId.value = 7
|
form.carrierId.value = 7
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remplit un bloc adresse complet (CP + ville + rue). */
|
/** Remplit l'unique bloc adresse (CP + ville + rue). */
|
||||||
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
|
function fillAddress(form: ReturnType<typeof useCarrierForm>): void {
|
||||||
const a = form.addresses.value[index]
|
const a = form.address.value
|
||||||
if (a) {
|
a.postalCode = '86100'
|
||||||
a.postalCode = '86100'
|
a.city = 'Châtellerault'
|
||||||
a.city = 'Châtellerault'
|
a.street = '1 rue du Test'
|
||||||
a.street = '1 rue du Test'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
|
it('submitAddress : POST sur /carriers/{id}/address, capture l\'id, finalise l\'onglet', async () => {
|
||||||
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 })
|
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||||
const form = createdForm()
|
const form = createdForm()
|
||||||
fillAddress(form)
|
fillAddress(form)
|
||||||
|
|
||||||
const ok = await form.submitAddresses(vi.fn())
|
const ok = await form.submitAddress(vi.fn())
|
||||||
|
|
||||||
expect(ok).toBe(true)
|
expect(ok).toBe(true)
|
||||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||||
expect(url).toBe('/carriers/7/addresses')
|
expect(url).toBe('/carriers/7/address')
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
country: 'France',
|
country: 'France',
|
||||||
postalCode: '86100',
|
postalCode: '86100',
|
||||||
@@ -513,24 +543,23 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
|||||||
streetComplement: null,
|
streetComplement: null,
|
||||||
})
|
})
|
||||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
expect(form.addresses.value[0]?.id).toBe(88)
|
expect(form.address.value.id).toBe(88)
|
||||||
expect(form.isValidated('addresses')).toBe(true)
|
expect(form.isValidated('addresses')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
|
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
|
||||||
mockPatch.mockResolvedValueOnce({})
|
mockPatch.mockResolvedValueOnce({})
|
||||||
const form = createdForm()
|
const form = createdForm()
|
||||||
fillAddress(form)
|
fillAddress(form)
|
||||||
const first = form.addresses.value[0]
|
form.address.value.id = 88
|
||||||
if (first) first.id = 88
|
|
||||||
|
|
||||||
await form.submitAddresses(vi.fn())
|
await form.submitAddress(vi.fn())
|
||||||
|
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
|
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 () => {
|
it('submitAddress : mappe les 422 inline par champ et ne finalise pas l\'onglet (RG-4.05)', async () => {
|
||||||
mockPost.mockRejectedValueOnce({
|
mockPost.mockRejectedValueOnce({
|
||||||
response: {
|
response: {
|
||||||
status: 422,
|
status: 422,
|
||||||
@@ -540,27 +569,12 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
|||||||
const form = createdForm()
|
const form = createdForm()
|
||||||
fillAddress(form)
|
fillAddress(form)
|
||||||
|
|
||||||
const ok = await form.submitAddresses(vi.fn())
|
const ok = await form.submitAddress(vi.fn())
|
||||||
|
|
||||||
expect(ok).toBe(false)
|
expect(ok).toBe(false)
|
||||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
expect(form.addressErrors.value.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
||||||
expect(form.isValidated('addresses')).toBe(false)
|
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', () => {
|
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
||||||
@@ -930,7 +944,7 @@ describe('useCarrierForm — édition (ERP-170)', () => {
|
|||||||
id: 7,
|
id: 7,
|
||||||
name: 'TRANSPORTS ACME',
|
name: 'TRANSPORTS ACME',
|
||||||
certificationType: 'GMP_PLUS',
|
certificationType: 'GMP_PLUS',
|
||||||
addresses: [{ '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }],
|
address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' },
|
||||||
contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }],
|
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' }],
|
prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }],
|
||||||
})
|
})
|
||||||
@@ -939,8 +953,7 @@ describe('useCarrierForm — édition (ERP-170)', () => {
|
|||||||
expect(form.editMode.value).toBe(true)
|
expect(form.editMode.value).toBe(true)
|
||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||||
expect(form.addresses.value).toHaveLength(1)
|
expect(form.address.value.id).toBe(3)
|
||||||
expect(form.addresses.value[0]?.id).toBe(3)
|
|
||||||
expect(form.contacts.value[0]?.id).toBe(9)
|
expect(form.contacts.value[0]?.id).toBe(9)
|
||||||
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
|
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
|
||||||
})
|
})
|
||||||
@@ -962,3 +975,73 @@ describe('useCarrierForm — édition (ERP-170)', () => {
|
|||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useCarrierForm — modification : Qualimat + certification (ERP-172)', () => {
|
||||||
|
const QUALIMAT_ROW = {
|
||||||
|
'@id': '/api/qualimat_carriers/42',
|
||||||
|
id: '42',
|
||||||
|
name: 'TRANSPORTS QUALIMAT',
|
||||||
|
address: '1 rue du Port',
|
||||||
|
postalCode: '86000',
|
||||||
|
city: 'Poitiers',
|
||||||
|
validityDate: '2027-01-15',
|
||||||
|
status: 'VALIDE',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCertification : quitter QUALIMAT délie la FK qualimatCarrier', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.main.qualimatCarrierIri = '/api/qualimat_carriers/42'
|
||||||
|
form.main.certificationType = 'QUALIMAT'
|
||||||
|
|
||||||
|
form.setCertification('GMP_PLUS')
|
||||||
|
|
||||||
|
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||||
|
expect(form.main.qualimatCarrierIri).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('certificationReadonly : éditable en modification même pour un QUALIMAT', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||||
|
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||||
|
})
|
||||||
|
expect(form.isQualimat.value).toBe(true)
|
||||||
|
expect(form.certificationReadonly.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildMainPayload : en modification, délie le Qualimat (qualimatCarrier: null) sans lien', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||||
|
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||||
|
})
|
||||||
|
form.setCertification('GMP_PLUS')
|
||||||
|
|
||||||
|
expect(form.buildMainPayload()).toMatchObject({ certificationType: 'GMP_PLUS', qualimatCarrier: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyQualimatSelection : en modification, conserve l\'adresse existante (PATCH nom/certif/FK)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'OLD', certificationType: 'GMP_PLUS',
|
||||||
|
address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers', street: 'rue A' },
|
||||||
|
})
|
||||||
|
const addressBefore = { ...form.address.value }
|
||||||
|
|
||||||
|
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
// Décision « conserver » (ERP-172) : l'adresse n'est pas réécrite en modification.
|
||||||
|
expect(form.address.value).toEqual(addressBefore)
|
||||||
|
// Nom + certification + FK actualisés via PATCH.
|
||||||
|
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
|
||||||
|
expect(form.main.certificationType).toBe('QUALIMAT')
|
||||||
|
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { useUpload } from '~/shared/composables/useUpload'
|
||||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
type CarrierMainResponse,
|
type CarrierMainResponse,
|
||||||
type CarrierPriceFormDraft,
|
type CarrierPriceFormDraft,
|
||||||
} from '~/modules/transport/types/carrierForm'
|
} from '~/modules/transport/types/carrierForm'
|
||||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
|
||||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
||||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +67,11 @@ export function useCarrierForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
const mainErrors = useFormErrors()
|
||||||
|
|
||||||
|
// Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents.
|
||||||
|
// L'upload est DIFFÉRÉ : le fichier choisi attend ici jusqu'à l'enregistrement.
|
||||||
|
const { uploading: dischargeUploading, upload: uploadFile } = useUpload()
|
||||||
|
const pendingDischargeFile = ref<File | null>(null)
|
||||||
|
|
||||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
// ── État du transporteur créé ─────────────────────────────────────────────
|
||||||
const carrierId = ref<number | null>(null)
|
const carrierId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -85,8 +91,10 @@ export function useCarrierForm() {
|
|||||||
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
||||||
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
||||||
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
||||||
|
// En MODIFICATION (ERP-172) : éditable même pour un QUALIMAT (le métier doit pouvoir
|
||||||
|
// changer la certification) — la sortie de QUALIMAT délie le référentiel.
|
||||||
const showCertification = computed(() => !isLiot.value)
|
const showCertification = computed(() => !isLiot.value)
|
||||||
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
const certificationReadonly = computed(() => (isQualimat.value && !editMode.value) || mainLocked.value)
|
||||||
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
||||||
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
||||||
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
||||||
@@ -140,8 +148,9 @@ export function useCarrierForm() {
|
|||||||
valid = false
|
valid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-4.02 : décharge obligatoire si certification AUTRE.
|
// RG-4.02 : décharge obligatoire si certification AUTRE — satisfaite par un
|
||||||
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) {
|
// IRI déjà posé OU un fichier en attente d'upload (différé à l'enregistrement).
|
||||||
|
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri && !pendingDischargeFile.value) {
|
||||||
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
||||||
valid = false
|
valid = false
|
||||||
}
|
}
|
||||||
@@ -165,6 +174,58 @@ export function useCarrierForm() {
|
|||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sélection de la décharge (RG-4.02) via `@file-selected` : le fichier est mis
|
||||||
|
* EN ATTENTE, l'upload réel est DIFFÉRÉ à l'enregistrement (`submitMain` /
|
||||||
|
* `updateMain`). Évite les binaires orphelins si l'utilisateur abandonne le
|
||||||
|
* formulaire après avoir choisi un fichier.
|
||||||
|
*/
|
||||||
|
function selectDischarge(file: File): void {
|
||||||
|
mainErrors.clearError('dischargeDocument')
|
||||||
|
pendingDischargeFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Annulation du choix de décharge : oublie le fichier en attente et l'IRI. */
|
||||||
|
function clearDischarge(): void {
|
||||||
|
pendingDischargeFile.value = null
|
||||||
|
main.dischargeDocumentIri = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout l'upload différé au moment de l'enregistrement : s'il y a un fichier
|
||||||
|
* en attente, l'envoie (POST /uploaded_documents) et pose l'IRI sur le
|
||||||
|
* brouillon. Retourne false au 422 (MIME / taille → message inline) pour
|
||||||
|
* interrompre la sauvegarde du transporteur. Pas de fichier en attente → no-op.
|
||||||
|
*/
|
||||||
|
async function resolveDischargeUpload(): Promise<boolean> {
|
||||||
|
if (!pendingDischargeFile.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
main.dischargeDocumentIri = await uploadFile(pendingDischargeFile.value)
|
||||||
|
pendingDischargeFile.value = null
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
const message = extractApiErrorMessage((error as { data?: unknown })?.data)
|
||||||
|
|| t('transport.carriers.form.errors.uploadFailed')
|
||||||
|
mainErrors.setError('dischargeDocument', message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change la certification (sélecteur). Quitter « QUALIMAT » délie le référentiel
|
||||||
|
* (FK qualimatCarrier vidée — ERP-172) : un transporteur n'est QUALIMAT que tant
|
||||||
|
* que sa certification l'est. La FK null est propagée au back par buildMainPayload
|
||||||
|
* (en modification uniquement).
|
||||||
|
*/
|
||||||
|
function setCertification(value: string | null): void {
|
||||||
|
main.certificationType = value
|
||||||
|
if (value !== 'QUALIMAT') {
|
||||||
|
main.qualimatCarrierIri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||||
@@ -190,9 +251,14 @@ export function useCarrierForm() {
|
|||||||
payload.certificationType = main.certificationType
|
payload.certificationType = main.certificationType
|
||||||
}
|
}
|
||||||
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
||||||
|
// En MODIFICATION, on délie explicitement (null) si plus de lien — ex: la
|
||||||
|
// certification a changé de QUALIMAT vers autre chose (ERP-172).
|
||||||
if (main.qualimatCarrierIri) {
|
if (main.qualimatCarrierIri) {
|
||||||
payload.qualimatCarrier = main.qualimatCarrierIri
|
payload.qualimatCarrier = main.qualimatCarrierIri
|
||||||
}
|
}
|
||||||
|
else if (editMode.value) {
|
||||||
|
payload.qualimatCarrier = null
|
||||||
|
}
|
||||||
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
||||||
// absente pour que la 422 « obligatoire » porte sur le champ.
|
// absente pour que la 422 « obligatoire » porte sur le champ.
|
||||||
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
||||||
@@ -227,6 +293,9 @@ export function useCarrierForm() {
|
|||||||
|
|
||||||
mainSubmitting.value = true
|
mainSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
|
// Upload différé de la décharge : envoyé seulement maintenant (au Valider).
|
||||||
|
if (!(await resolveDischargeUpload())) return false
|
||||||
|
|
||||||
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
||||||
headers: { Accept: 'application/ld+json' },
|
headers: { Accept: 'application/ld+json' },
|
||||||
toast: false,
|
toast: false,
|
||||||
@@ -277,6 +346,9 @@ export function useCarrierForm() {
|
|||||||
|
|
||||||
mainSubmitting.value = true
|
mainSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
|
// Upload différé de la décharge : envoyé seulement maintenant (à l'Enregistrer).
|
||||||
|
if (!(await resolveDischargeUpload())) return false
|
||||||
|
|
||||||
const updated = await api.patch<CarrierMainResponse>(
|
const updated = await api.patch<CarrierMainResponse>(
|
||||||
`/carriers/${carrierId.value}`,
|
`/carriers/${carrierId.value}`,
|
||||||
buildMainPayload(),
|
buildMainPayload(),
|
||||||
@@ -317,8 +389,8 @@ export function useCarrierForm() {
|
|||||||
|
|
||||||
Object.assign(main, mapMainToDraft(detail))
|
Object.assign(main, mapMainToDraft(detail))
|
||||||
|
|
||||||
const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft)
|
// Adresse UNIQUE (ERP-172) : objet `address` (ou null) au lieu d'une liste.
|
||||||
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()]
|
address.value = detail.address ? mapAddressToDraft(detail.address) : emptyCarrierAddress()
|
||||||
|
|
||||||
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
|
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
|
||||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
|
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
|
||||||
@@ -383,75 +455,52 @@ export function useCarrierForm() {
|
|||||||
return hasError
|
return hasError
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
|
// ── Onglet Adresse (ERP-167 / ERP-172 : adresse UNIQUE) ───────────────────
|
||||||
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
|
// Un transporteur a au plus UNE adresse (décision métier ERP-172) : un seul
|
||||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
// bloc, pas d'ajout/suppression. `id` null tant que l'adresse n'est pas créée.
|
||||||
const addressErrors = ref<Record<string, string>[]>([])
|
const address = ref<CarrierAddressFormDraft>(emptyCarrierAddress())
|
||||||
|
// Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101).
|
||||||
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
|
const addressErrors = ref<Record<string, string>>({})
|
||||||
// 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
|
* Valide l'onglet Adresse : POST sur /carriers/{id}/address (création) ou PATCH
|
||||||
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
|
* sur /carrier_addresses/{id} (mise à jour), groupe carrier:write:addresses.
|
||||||
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
|
* Erreurs 422 mappées inline par champ (RG-4.05 « obligatoire si affrété »
|
||||||
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
|
* re-validée back). Retourne true si l'onglet a été validé.
|
||||||
* validé (avancé/terminé).
|
|
||||||
*/
|
*/
|
||||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
async function submitAddress(onError: (error: unknown) => void): Promise<boolean> {
|
||||||
if (carrierId.value === null || tabSubmitting.value) {
|
if (carrierId.value === null || tabSubmitting.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
|
addressErrors.value = {}
|
||||||
try {
|
try {
|
||||||
const hasError = await submitRows(
|
const body = buildCarrierAddressPayload(address.value)
|
||||||
addresses.value,
|
if (address.value.id === null) {
|
||||||
addressErrors,
|
const created = await api.post<{ id: number }>(
|
||||||
async (address) => {
|
`/carriers/${carrierId.value}/address`,
|
||||||
const body = buildCarrierAddressPayload(address)
|
body,
|
||||||
if (address.id === null) {
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
const created = await api.post<{ id: number }>(
|
)
|
||||||
`/carriers/${carrierId.value}/addresses`,
|
address.value.id = created.id
|
||||||
body,
|
}
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
else {
|
||||||
)
|
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
|
||||||
address.id = created.id
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError,
|
|
||||||
)
|
|
||||||
if (hasError) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
completeTab('addresses')
|
completeTab('addresses')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
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) {
|
||||||
|
addressErrors.value = mapped
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
finally {
|
finally {
|
||||||
tabSubmitting.value = false
|
tabSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -689,16 +738,20 @@ export function useCarrierForm() {
|
|||||||
city: row.city ?? '',
|
city: row.city ?? '',
|
||||||
street: row.address ?? '',
|
street: row.address ?? '',
|
||||||
}
|
}
|
||||||
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
|
// RG-4.05 : à la CRÉATION, pré-remplit l'adresse (unique) par copie du
|
||||||
// QUALIMAT survit, les champs restent éditables — § 2.5).
|
// référentiel QUALIMAT (champs éditables, la FK QUALIMAT survit — § 2.5).
|
||||||
addresses.value = [{
|
// En MODIFICATION (ERP-172) : on NE TOUCHE PAS l'adresse déjà saisie — la
|
||||||
id: null,
|
// re-sélection Qualimat actualise seulement nom + certification + FK.
|
||||||
country: 'France',
|
if (!editMode.value) {
|
||||||
postalCode: row.postalCode || null,
|
address.value = {
|
||||||
city: row.city || null,
|
id: null,
|
||||||
street: row.address || null,
|
country: 'France',
|
||||||
streetComplement: null,
|
postalCode: row.postalCode || null,
|
||||||
}]
|
city: row.city || null,
|
||||||
|
street: row.address || null,
|
||||||
|
streetComplement: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,6 +785,7 @@ export function useCarrierForm() {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
// affichage conditionnel
|
// affichage conditionnel
|
||||||
isLiot,
|
isLiot,
|
||||||
isQualimat,
|
isQualimat,
|
||||||
@@ -746,13 +800,10 @@ export function useCarrierForm() {
|
|||||||
validated,
|
validated,
|
||||||
editMode,
|
editMode,
|
||||||
isValidated,
|
isValidated,
|
||||||
// adresses
|
// adresse (unique)
|
||||||
addresses,
|
address,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
canAddAddress,
|
submitAddress,
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
// contacts
|
// contacts
|
||||||
contacts,
|
contacts,
|
||||||
contactErrors,
|
contactErrors,
|
||||||
@@ -768,6 +819,9 @@ export function useCarrierForm() {
|
|||||||
removePrice,
|
removePrice,
|
||||||
submitPrices,
|
submitPrices,
|
||||||
// actions
|
// actions
|
||||||
|
setCertification,
|
||||||
|
selectDischarge,
|
||||||
|
clearDischarge,
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
|
|||||||
@@ -41,16 +41,20 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
:readonly="certificationReadonly"
|
:readonly="certificationReadonly"
|
||||||
:error="mainErrors.errors.certificationType"
|
:error="mainErrors.errors.certificationType"
|
||||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
|
||||||
/>
|
/>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
v-if="showDischarge"
|
v-if="showDischarge"
|
||||||
|
:model-value="dischargeFileName"
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
:label="t('transport.carriers.form.main.discharge')"
|
||||||
accept="application/pdf,image/*"
|
accept="application/pdf,image/*"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:readonly="dischargeUploading"
|
||||||
:clearable="true"
|
:clearable="true"
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
:error="mainErrors.errors.dischargeDocument"
|
||||||
@clear="main.dischargeDocumentIri = null"
|
@update:model-value="(v: string) => dischargeFileName = v"
|
||||||
|
@file-selected="selectDischarge"
|
||||||
|
@clear="onClearDischarge"
|
||||||
/>
|
/>
|
||||||
<div v-else class="hidden xl:block"></div>
|
<div v-else class="hidden xl:block"></div>
|
||||||
<div class="flex h-12 items-center">
|
<div class="flex h-12 items-center">
|
||||||
@@ -118,21 +122,26 @@
|
|||||||
|
|
||||||
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
|
<!-- Qualimat : actualiser nom + certification depuis le référentiel (ERP-172). -->
|
||||||
|
<template #qualimat>
|
||||||
|
<CarrierQualimatTab
|
||||||
|
:search-name="main.name"
|
||||||
|
:selected-iri="main.qualimatCarrierIri"
|
||||||
|
@integrate="onIntegrateQualimat"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #addresses>
|
<template #addresses>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="isRowRemovable(addresses, index)"
|
:errors="addressErrors"
|
||||||
:errors="addressErrors[index]"
|
@update:model-value="(v) => address = v"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
|
||||||
@remove="askRemoveAddress(index)"
|
|
||||||
@degraded="onAddressDegraded"
|
@degraded="onAddressDegraded"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-6">
|
<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" />
|
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +209,10 @@ import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
|
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||||
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
@@ -231,16 +242,18 @@ const {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
|
selectDischarge,
|
||||||
|
clearDischarge,
|
||||||
|
setCertification,
|
||||||
isLiot,
|
isLiot,
|
||||||
certificationReadonly,
|
certificationReadonly,
|
||||||
showCharteredFields,
|
showCharteredFields,
|
||||||
showDischarge,
|
showDischarge,
|
||||||
addresses,
|
applyQualimatSelection,
|
||||||
|
address,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
canAddAddress,
|
submitAddress,
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
contacts,
|
contacts,
|
||||||
contactErrors,
|
contactErrors,
|
||||||
canAddContact,
|
canAddContact,
|
||||||
@@ -266,12 +279,14 @@ const certificationOptions = computed<SelectOption[]>(() => {
|
|||||||
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
qualimat: 'mdi:truck-fast-outline',
|
||||||
addresses: 'mdi:map-marker-outline',
|
addresses: 'mdi:map-marker-outline',
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
contacts: 'mdi:account-box-plus-outline',
|
||||||
prices: 'mdi:payment',
|
prices: 'mdi:payment',
|
||||||
}
|
}
|
||||||
const activeTab = ref('addresses')
|
const activeTab = ref('addresses')
|
||||||
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
|
||||||
|
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
|
||||||
key,
|
key,
|
||||||
label: t(`transport.carriers.tab.${key}`),
|
label: t(`transport.carriers.tab.${key}`),
|
||||||
icon: TAB_ICONS[key],
|
icon: TAB_ICONS[key],
|
||||||
@@ -307,6 +322,12 @@ onMounted(async () => {
|
|||||||
await load()
|
await load()
|
||||||
if (carrier.value) {
|
if (carrier.value) {
|
||||||
prefillFrom(carrier.value)
|
prefillFrom(carrier.value)
|
||||||
|
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
|
||||||
|
const doc = carrier.value.dischargeDocument
|
||||||
|
if (doc && typeof doc !== 'string') {
|
||||||
|
const meta = doc as Record<string, unknown>
|
||||||
|
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadCountries().catch(() => {})
|
loadCountries().catch(() => {})
|
||||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
||||||
@@ -319,6 +340,16 @@ function apiErrorMessage(err: unknown): string {
|
|||||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection ou au
|
||||||
|
// chargement d'un transporteur ayant déjà une décharge).
|
||||||
|
const dischargeFileName = ref('')
|
||||||
|
|
||||||
|
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
|
||||||
|
function onClearDischarge(): void {
|
||||||
|
clearDischarge()
|
||||||
|
dischargeFileName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
||||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
||||||
const indexationKey = ref(0)
|
const indexationKey = ref(0)
|
||||||
@@ -344,8 +375,17 @@ async function onUpdateMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Intégration d'une ligne QUALIMAT (onglet Qualimat) : actualise nom + certification
|
||||||
|
* + FK via PATCH (applyQualimatSelection). L'adresse existante n'est pas touchée. */
|
||||||
|
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||||
|
const ok = await applyQualimatSelection(row)
|
||||||
|
if (ok) {
|
||||||
|
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmitAddresses(): Promise<void> {
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
const ok = await submitAddresses(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||||
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
||||||
}
|
}
|
||||||
async function onSubmitContacts(): Promise<void> {
|
async function onSubmitContacts(): Promise<void> {
|
||||||
@@ -360,10 +400,6 @@ async function onSubmitPrices(): Promise<void> {
|
|||||||
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
|
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
|
||||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
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 {
|
function askRemoveContact(index: number): void {
|
||||||
deleteConfirm.action = () => { void removeContact(index) }
|
deleteConfirm.action = () => { void removeContact(index) }
|
||||||
deleteConfirm.open = true
|
deleteConfirm.open = true
|
||||||
|
|||||||
@@ -116,9 +116,8 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
<template #addresses>
|
<template #addresses>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- Adresse UNIQUE (ERP-172). -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:country-options="countryOptionsFor(address.country)"
|
:country-options="countryOptionsFor(address.country)"
|
||||||
readonly
|
readonly
|
||||||
@@ -145,11 +144,11 @@
|
|||||||
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
|
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
|
||||||
épais entre les deux groupes. -->
|
é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">
|
<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
|
<!-- Répartition (table-fixed) : « Type de transport » un peu plus
|
||||||
et Adresse livraisons larges ; Forfait / Tonne / Indexation / État
|
large ; Transporteurs et Adresse livraisons larges ; Forfait /
|
||||||
réduits. -->
|
Tonne / Indexation / État réduits. -->
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-[110px]" />
|
<col class="w-[170px]" />
|
||||||
<col class="w-[20%]" />
|
<col class="w-[20%]" />
|
||||||
<col class="w-[11%]" />
|
<col class="w-[11%]" />
|
||||||
<col class="w-[24%]" />
|
<col class="w-[24%]" />
|
||||||
@@ -160,7 +159,8 @@
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
<!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. -->
|
||||||
|
<th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center 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.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.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.delivery') }}</th>
|
||||||
@@ -254,6 +254,7 @@ import {
|
|||||||
showArchiveAction,
|
showArchiveAction,
|
||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type CarrierPriceRead,
|
type CarrierPriceRead,
|
||||||
|
type Relation,
|
||||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
@@ -311,11 +312,10 @@ const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
|||||||
icon: TAB_ICONS[key],
|
icon: TAB_ICONS[key],
|
||||||
})))
|
})))
|
||||||
|
|
||||||
// Au moins un bloc affiché même sans donnée (bloc vide en lecture seule).
|
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
|
||||||
const addresses = computed(() => {
|
const address = computed(() => carrier.value?.address
|
||||||
const list = (carrier.value?.addresses ?? []).map(mapAddressToDraft)
|
? mapAddressToDraft(carrier.value.address)
|
||||||
return list.length > 0 ? list : [mapAddressToDraft({ id: 0, '@id': '' })]
|
: mapAddressToDraft({ id: 0, '@id': '' }))
|
||||||
})
|
|
||||||
const contacts = computed(() => {
|
const contacts = computed(() => {
|
||||||
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
|
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
|
||||||
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
|
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
|
||||||
@@ -356,16 +356,25 @@ function formatAmount(value: string | null | undefined): string {
|
|||||||
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Code du site = département (2 premiers chiffres du code postal, ex: 86 / 17 / 82). */
|
||||||
|
function siteCode(relation: Relation): string {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const postalCode = relation.postalCode as string | undefined
|
||||||
|
return postalCode ? postalCode.slice(0, 2) : ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
|
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
|
||||||
* - « Adresse sites » = le site (Châtellerault / Saint-Jean / Pommevic…) ;
|
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ;
|
||||||
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
|
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
|
||||||
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
|
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
|
||||||
*/
|
*/
|
||||||
function toPriceRow(price: CarrierPriceRead): PriceRowView {
|
function toPriceRow(price: CarrierPriceRead): PriceRowView {
|
||||||
const isClient = price.direction === 'CLIENT'
|
const isClient = price.direction === 'CLIENT'
|
||||||
return {
|
return {
|
||||||
apro: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.deliverySite),
|
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
|
||||||
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
||||||
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
||||||
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
|
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
|
||||||
|
|||||||
@@ -53,18 +53,20 @@
|
|||||||
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
||||||
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
||||||
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
||||||
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
|
Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente
|
||||||
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
et envoyé seulement à la validation du formulaire. -->
|
||||||
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
v-if="showDischarge"
|
v-if="showDischarge"
|
||||||
|
:model-value="dischargeFileName"
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
:label="t('transport.carriers.form.main.discharge')"
|
||||||
accept="application/pdf,image/*"
|
accept="application/pdf,image/*"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:readonly="mainLocked || dischargeUploading"
|
||||||
:clearable="true"
|
:clearable="true"
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
:error="mainErrors.errors.dischargeDocument"
|
||||||
@clear="main.dischargeDocumentIri = null"
|
@update:model-value="(v: string) => dischargeFileName = v"
|
||||||
|
@file-selected="selectDischarge"
|
||||||
|
@clear="onClearDischarge"
|
||||||
/>
|
/>
|
||||||
<div v-else class="hidden xl:block"></div>
|
<div v-else class="hidden xl:block"></div>
|
||||||
|
|
||||||
@@ -154,75 +156,32 @@
|
|||||||
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
||||||
tickets suivants (placeholders « A venir »). -->
|
tickets suivants (placeholders « A venir »). -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
<!-- Onglet Qualimat : saisie assistée (recherche par nom). Composant
|
||||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
mutualisé avec l'écran de modification (ERP-172). -->
|
||||||
<template #qualimat>
|
<template #qualimat>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<CarrierQualimatTab
|
||||||
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
:search-name="main.name"
|
||||||
<MalioDataTable
|
:selected-iri="main.qualimatCarrierIri"
|
||||||
class="qualimat-table"
|
@integrate="onIntegrateQualimat"
|
||||||
table-class="table-fixed"
|
/>
|
||||||
:columns="qualimatColumns"
|
|
||||||
:items="qualimatRows"
|
|
||||||
:total-items="qualimatTotalDisplay"
|
|
||||||
:page="qualimatPage"
|
|
||||||
:per-page="qualimatPerPage"
|
|
||||||
:per-page-options="qualimatPerPageOptions"
|
|
||||||
row-clickable
|
|
||||||
:empty-message="qualimatEmptyMessage"
|
|
||||||
@row-click="onQualimatRowClick"
|
|
||||||
@update:page="qualimatGoToPage"
|
|
||||||
@update:per-page="qualimatSetPerPage"
|
|
||||||
>
|
|
||||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
|
||||||
<template #cell-select="{ item }">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.qualimatCarrierIri"
|
|
||||||
name="qualimat-row"
|
|
||||||
:value="item.iri"
|
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
|
||||||
<template #cell-validityDate="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="item.validityDate"
|
|
||||||
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
|
||||||
>
|
|
||||||
{{ formatDateFr(item.validityDate as string) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</MalioDataTable>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
||||||
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
||||||
<template #addresses>
|
<template #addresses>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||||
<CarrierAddressBlock
|
<CarrierAddressBlock
|
||||||
v-for="(address, index) in addresses"
|
|
||||||
:key="index"
|
|
||||||
:model-value="address"
|
:model-value="address"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="isRowRemovable(addresses, index)"
|
|
||||||
:readonly="isQualimat || isValidated('addresses')"
|
:readonly="isQualimat || isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => address = v"
|
||||||
@remove="askRemoveAddress(index)"
|
|
||||||
@degraded="onAddressDegraded"
|
@degraded="onAddressDegraded"
|
||||||
/>
|
/>
|
||||||
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
|
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
|
||||||
(adresse copiée et persistée automatiquement). -->
|
(adresse copiée et persistée automatiquement). -->
|
||||||
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
|
<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
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('transport.carriers.form.submit')"
|
:label="t('transport.carriers.form.submit')"
|
||||||
@@ -314,29 +273,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
|
|
||||||
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
<!-- Modal de confirmation de suppression (bloc contact / prix). -->
|
||||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
|
||||||
</template>
|
|
||||||
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
|
||||||
<template #footer>
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
|
||||||
@click="confirmOpen = false"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="primary"
|
|
||||||
button-class="flex-1"
|
|
||||||
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
|
||||||
@click="confirmIntegrate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MalioModal>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation de suppression (bloc adresse). -->
|
|
||||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
||||||
@@ -361,15 +298,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { debounce } from '~/shared/utils/debounce'
|
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
|
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
@@ -398,6 +335,9 @@ const {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
|
selectDischarge,
|
||||||
|
clearDischarge,
|
||||||
isLiot,
|
isLiot,
|
||||||
isQualimat,
|
isQualimat,
|
||||||
certificationReadonly,
|
certificationReadonly,
|
||||||
@@ -407,12 +347,9 @@ const {
|
|||||||
activeTab,
|
activeTab,
|
||||||
unlockedIndex,
|
unlockedIndex,
|
||||||
isValidated,
|
isValidated,
|
||||||
addresses,
|
address,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
canAddAddress,
|
submitAddress,
|
||||||
addAddress,
|
|
||||||
removeAddress,
|
|
||||||
submitAddresses,
|
|
||||||
contacts,
|
contacts,
|
||||||
contactErrors,
|
contactErrors,
|
||||||
canAddContact,
|
canAddContact,
|
||||||
@@ -429,16 +366,14 @@ const {
|
|||||||
applyQualimatSelection,
|
applyQualimatSelection,
|
||||||
} = useCarrierForm()
|
} = useCarrierForm()
|
||||||
|
|
||||||
const {
|
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection).
|
||||||
items: qualimatItems,
|
const dischargeFileName = ref('')
|
||||||
totalItems: qualimatTotal,
|
|
||||||
currentPage: qualimatPage,
|
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
|
||||||
itemsPerPage: qualimatPerPage,
|
function onClearDischarge(): void {
|
||||||
itemsPerPageOptions: qualimatPerPageOptions,
|
clearDischarge()
|
||||||
goToPage: qualimatGoToPage,
|
dischargeFileName.value = ''
|
||||||
setItemsPerPage: qualimatSetPerPage,
|
}
|
||||||
setFilters: qualimatSetFilters,
|
|
||||||
} = useQualimatSearch()
|
|
||||||
|
|
||||||
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
||||||
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
||||||
@@ -458,40 +393,6 @@ const certificationOptions = computed<SelectOption[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Colonnes du datatable de selection QUALIMAT (radio / Nom / Adresse / Validite).
|
|
||||||
const qualimatColumns = [
|
|
||||||
{ key: 'select', label: '' },
|
|
||||||
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
|
||||||
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
|
||||||
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
|
||||||
// pas saisi (pas de liste complète par défaut). `main.name` pilote l'affichage.
|
|
||||||
const hasQualimatSearch = computed(() => main.name.trim() !== '')
|
|
||||||
|
|
||||||
// Lignes « plates » pour MalioDataTable (l'IRI sert au radio + à retrouver la ligne
|
|
||||||
// source au clic). Le détail QUALIMAT complet reste dans `qualimatItems`.
|
|
||||||
const qualimatRows = computed(() => {
|
|
||||||
if (!hasQualimatSearch.value) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return qualimatItems.value.map(row => ({
|
|
||||||
id: row.id,
|
|
||||||
iri: row['@id'],
|
|
||||||
name: row.name,
|
|
||||||
address: formatQualimatAddress(row),
|
|
||||||
validityDate: row.validityDate,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Total / message vide alignés sur « tableau vide tant qu'on n'a pas recherché ».
|
|
||||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
|
||||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
|
||||||
? t('transport.carriers.form.qualimat.empty')
|
|
||||||
: t('transport.carriers.form.qualimat.searchHint'))
|
|
||||||
|
|
||||||
|
|
||||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
qualimat: 'mdi:truck-fast-outline',
|
qualimat: 'mdi:truck-fast-outline',
|
||||||
@@ -595,7 +496,7 @@ function apiErrorMessage(error: unknown): string {
|
|||||||
|
|
||||||
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
|
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
|
||||||
async function onSubmitAddresses(): Promise<void> {
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
const ok = await submitAddresses(error => toast.error({
|
const ok = await submitAddress(error => toast.error({
|
||||||
title: t('transport.carriers.toast.error'),
|
title: t('transport.carriers.toast.error'),
|
||||||
message: apiErrorMessage(error),
|
message: apiErrorMessage(error),
|
||||||
}))
|
}))
|
||||||
@@ -604,14 +505,9 @@ async function onSubmitAddresses(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal de confirmation de suppression (générique : bloc adresse OU contact).
|
// Modal de confirmation de suppression (générique : bloc contact OU prix).
|
||||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
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). */
|
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
|
||||||
async function onSubmitContacts(): Promise<void> {
|
async function onSubmitContacts(): Promise<void> {
|
||||||
const ok = await submitContacts(error => toast.error({
|
const ok = await submitContacts(error => toast.error({
|
||||||
@@ -628,7 +524,10 @@ function askRemoveContact(index: number): void {
|
|||||||
deleteConfirm.open = true
|
deleteConfirm.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Prix (POST/PATCH par ligne ; avance gérée par le composable). */
|
/**
|
||||||
|
* Valide l'onglet Prix = DERNIER onglet du flux de création. Au succès, l'ajout est
|
||||||
|
* terminé : toast final + retour au répertoire transporteurs (aligné sur M1/M2/M3).
|
||||||
|
*/
|
||||||
async function onSubmitPrices(): Promise<void> {
|
async function onSubmitPrices(): Promise<void> {
|
||||||
const ok = await submitPrices(error => toast.error({
|
const ok = await submitPrices(error => toast.error({
|
||||||
title: t('transport.carriers.toast.error'),
|
title: t('transport.carriers.toast.error'),
|
||||||
@@ -636,6 +535,7 @@ async function onSubmitPrices(): Promise<void> {
|
|||||||
}))
|
}))
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
||||||
|
await navigateTo('/carriers')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,74 +550,9 @@ function runDeleteConfirm(): void {
|
|||||||
deleteConfirm.open = false
|
deleteConfirm.open = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
/** Intégration d'une ligne QUALIMAT (émise par CarrierQualimatTab) : copie + PATCH
|
||||||
const confirmOpen = ref(false)
|
* (cf. useCarrierForm.applyQualimatSelection). */
|
||||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||||
|
|
||||||
// Le datatable QUALIMAT est filtré par le NOM saisi dans le formulaire principal
|
|
||||||
// (RG-4.01) — pas de champ de recherche dédié. Aucune recherche tant que le Nom est
|
|
||||||
// vide (tableau vide par défaut) ; sinon re-filtrage debouncé à chaque frappe.
|
|
||||||
const filterQualimatByName = debounce((term: string) => {
|
|
||||||
if (term.trim() === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void qualimatSetFilters({ search: term })
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
watch(() => main.name, term => filterQualimatByName(term))
|
|
||||||
|
|
||||||
/** Adresse QUALIMAT condensee pour la colonne « Adresse » (voie · CP · ville). */
|
|
||||||
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
|
||||||
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RG-4.04 : un agrement est perime si sa date de validite est < aujourd'hui. */
|
|
||||||
function isExpired(value: string): boolean {
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
date.setHours(0, 0, 0, 0)
|
|
||||||
return date.getTime() < today.getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format court francais JJ-MM-AAAA (chaine vide si date absente / invalide). */
|
|
||||||
function formatDateFr(value: string | null | undefined): string {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const date = new Date(value)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${day}-${month}-${date.getFullYear()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clic sur une ligne du datatable → retrouve la ligne QUALIMAT source + modal. */
|
|
||||||
function onQualimatRowClick(item: Record<string, unknown>): void {
|
|
||||||
const row = qualimatItems.value.find(r => r.id === item.id)
|
|
||||||
if (row) {
|
|
||||||
askIntegrate(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ouvre la modal de confirmation d'integration pour une ligne QUALIMAT. */
|
|
||||||
function askIntegrate(row: QualimatCarrierRow): void {
|
|
||||||
pendingRow.value = row
|
|
||||||
confirmOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirme l'integration : copie + PATCH (cf. useCarrierForm.applyQualimatSelection). */
|
|
||||||
async function confirmIntegrate(): Promise<void> {
|
|
||||||
const row = pendingRow.value
|
|
||||||
confirmOpen.value = false
|
|
||||||
if (row === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ok = await applyQualimatSelection(row)
|
const ok = await applyQualimatSelection(row)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||||
@@ -750,19 +585,10 @@ function goBack(): void {
|
|||||||
async function onSubmitMain(): Promise<void> {
|
async function onSubmitMain(): Promise<void> {
|
||||||
const ok = await submitMain()
|
const ok = await submitMain()
|
||||||
if (ok && isQualimat.value) {
|
if (ok && isQualimat.value) {
|
||||||
await submitAddresses(error => toast.error({
|
await submitAddress(error => toast.error({
|
||||||
title: t('transport.carriers.toast.error'),
|
title: t('transport.carriers.toast.error'),
|
||||||
message: apiErrorMessage(error),
|
message: apiErrorMessage(error),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -8,18 +8,7 @@
|
|||||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
|
* Payload de la sous-ressource address (groupe `carrier:write:addresses`). Les
|
||||||
* (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
|
* 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
|
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
|
||||||
* renvoie une 422 par champ).
|
* renvoie une 422 par champ).
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export interface CarrierDetail extends HydraRef {
|
|||||||
dischargeDocument?: Relation
|
dischargeDocument?: Relation
|
||||||
qualimatCarrier?: Relation
|
qualimatCarrier?: Relation
|
||||||
isArchived?: boolean
|
isArchived?: boolean
|
||||||
addresses?: CarrierAddressRead[]
|
// Adresse UNIQUE (OneToOne, ERP-172) : objet embarqué (ou absent), pas une liste.
|
||||||
|
address?: CarrierAddressRead | null
|
||||||
contacts?: CarrierContactRead[]
|
contacts?: CarrierContactRead[]
|
||||||
prices?: CarrierPriceRead[]
|
prices?: CarrierPriceRead[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composable d'upload générique (ERP-171) :
|
||||||
|
* - succès : POST multipart /uploaded_documents (champ « file »), toast désactivé,
|
||||||
|
* renvoie l'IRI (@id) du document créé, `uploading` retombe à false ;
|
||||||
|
* - erreur MIME hors whitelist → 422 : l'erreur est RELAYÉE à l'appelant (pour un
|
||||||
|
* affichage inline sous le champ), `uploading` ré-armé via le finally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useUpload } = await import('../useUpload')
|
||||||
|
|
||||||
|
describe('useUpload', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succès : POST multipart champ « file » + toast:false → renvoie l\'IRI', async () => {
|
||||||
|
mockPost.mockResolvedValue({ '@id': '/api/uploaded_documents/9', originalFilename: 'decharge.pdf' })
|
||||||
|
const { upload, uploading } = useUpload()
|
||||||
|
const file = new File(['contenu'], 'decharge.pdf', { type: 'application/pdf' })
|
||||||
|
|
||||||
|
const iri = await upload(file)
|
||||||
|
|
||||||
|
expect(iri).toBe('/api/uploaded_documents/9')
|
||||||
|
|
||||||
|
const [url, body, options] = mockPost.mock.calls[0]
|
||||||
|
expect(url).toBe('/uploaded_documents')
|
||||||
|
expect(body).toBeInstanceOf(FormData)
|
||||||
|
const stored = (body as FormData).get('file')
|
||||||
|
expect(stored).toBeInstanceOf(File)
|
||||||
|
expect((stored as File).name).toBe('decharge.pdf')
|
||||||
|
expect(options).toMatchObject({ toast: false })
|
||||||
|
|
||||||
|
expect(uploading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur MIME → 422 : l\'erreur est remontée à l\'appelant', async () => {
|
||||||
|
const error = Object.assign(new Error('422'), {
|
||||||
|
data: { 'hydra:description': 'Type de fichier non autorisé.' },
|
||||||
|
})
|
||||||
|
mockPost.mockRejectedValue(error)
|
||||||
|
const { upload, uploading } = useUpload()
|
||||||
|
const file = new File(['x'], 'malware.exe', { type: 'application/x-msdownload' })
|
||||||
|
|
||||||
|
await expect(upload(file)).rejects.toBe(error)
|
||||||
|
expect(uploading.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { AnyObject } from '~/shared/composables/useApi'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse JSON-LD de POST /api/uploaded_documents (groupe `uploaded_document:read`).
|
||||||
|
* Seul l'IRI (`@id`) est exploité pour le poser sur la relation cible.
|
||||||
|
*/
|
||||||
|
export interface UploadedDocumentResponse {
|
||||||
|
'@id': string
|
||||||
|
originalFilename?: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload d'un document générique vers l'infra partagée (ERP-154) :
|
||||||
|
* POST /api/uploaded_documents en multipart/form-data, champ « file », via
|
||||||
|
* `useApi()` (cookie JWT, parsing Hydra/erreurs). Renvoie l'IRI du document créé,
|
||||||
|
* à poser sur la relation cible (ex: `carrier.dischargeDocument` — RG-4.02).
|
||||||
|
*
|
||||||
|
* Les erreurs (MIME hors whitelist / fichier trop volumineux → 422) sont relayées
|
||||||
|
* (rethrow) à l'appelant pour un affichage inline sous le champ. `toast: false` par
|
||||||
|
* défaut : pas de toast fourre-tout, le formulaire mappe le message au bon champ.
|
||||||
|
*/
|
||||||
|
export function useUpload() {
|
||||||
|
// Indicateur d'upload en cours (désactivation UI / spinner éventuel).
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie `file` et renvoie l'IRI du `UploadedDocument` créé.
|
||||||
|
* @throws relaie l'erreur réseau / 422 (MIME, taille) à l'appelant.
|
||||||
|
*/
|
||||||
|
async function upload(file: File, options: { toast?: boolean } = {}): Promise<string> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
// useApi() détecte le FormData et n'impose pas de Content-Type JSON :
|
||||||
|
// le navigateur pose lui-même la frontière multipart.
|
||||||
|
const doc = await useApi().post<UploadedDocumentResponse>(
|
||||||
|
'/uploaded_documents',
|
||||||
|
formData as unknown as AnyObject,
|
||||||
|
{ toast: options.toast ?? false },
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc['@id']
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploading, upload }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-172 — adresse UNIQUE par transporteur (decision metier : un transporteur a
|
||||||
|
* au plus une adresse). Bascule la relation carrier_address.carrier_id en OneToOne :
|
||||||
|
* remplace l'index simple idx_carrier_address_carrier par une contrainte d'unicite
|
||||||
|
* uniq_carrier_address_carrier. La garde applicative (CarrierAddressProcessor) renvoie
|
||||||
|
* un 409 explicite avant d'atteindre cette contrainte.
|
||||||
|
*
|
||||||
|
* 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 Version20260617140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-172 : adresse unique par transporteur — index unique sur carrier_address.carrier_id (OneToOne).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX idx_carrier_address_carrier');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX uniq_carrier_address_carrier');
|
||||||
|
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-172 — nettoyage des reliquats du multi-adresses sur carrier_address, suite a
|
||||||
|
* la bascule en adresse UNIQUE (Version20260617140000). La colonne `position`
|
||||||
|
* servait a ordonner une LISTE d'adresses ; avec une seule adresse par transporteur
|
||||||
|
* (OneToOne) elle n'a plus de sens -> on la supprime. On reactualise aussi le
|
||||||
|
* COMMENT ON TABLE qui annoncait encore une relation 1:n.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* et apres la bascule OneToOne (cf. CLAUDE.md regle 11).
|
||||||
|
*/
|
||||||
|
final class Version20260617160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-172 : retire la colonne carrier_address.position (relique multi-adresses) + COMMENT ON TABLE 1:1.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE carrier_address DROP COLUMN position');
|
||||||
|
$this->addSql("COMMENT ON TABLE carrier_address IS 'Adresse d un transporteur (1:1, OneToOne — ERP-172 : adresse UNIQUE) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE carrier_address ADD COLUMN position INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql("COMMENT ON COLUMN carrier_address.position IS 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).'");
|
||||||
|
$this->addSql("COMMENT ON TABLE carrier_address IS 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'supplier:read',
|
'supplier:read',
|
||||||
'supplier_address:read',
|
'supplier_address:read',
|
||||||
'site:read',
|
'site:read',
|
||||||
|
// Embarque le nom de fichier de la decharge (RG-4.02) au lieu d'un
|
||||||
|
// IRI nu, pour l'affichage en consultation / modification (ERP-171).
|
||||||
|
'uploaded_document:reference',
|
||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
provider: CarrierProvider::class,
|
provider: CarrierProvider::class,
|
||||||
@@ -195,10 +198,13 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||||
private ?string $liotPlates = null;
|
private ?string $liotPlates = null;
|
||||||
|
|
||||||
|
// === Adresse UNIQUE (OneToOne) — EMBARQUEE dans le DETAIL (read-group sur le getter) ===
|
||||||
|
// Metier : un transporteur a au plus UNE adresse (decision metier ERP-172). La
|
||||||
|
// FK porte un index UNIQUE (cote CarrierAddress.carrier en OneToOne owning side).
|
||||||
|
#[ORM\OneToOne(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
private ?CarrierAddress $address = null;
|
||||||
|
|
||||||
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
||||||
/** @var Collection<int, CarrierAddress> */
|
|
||||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
|
||||||
private Collection $addresses;
|
|
||||||
|
|
||||||
/** @var Collection<int, CarrierContact> */
|
/** @var Collection<int, CarrierContact> */
|
||||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
@@ -225,9 +231,8 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->addresses = new ArrayCollection();
|
$this->contacts = new ArrayCollection();
|
||||||
$this->contacts = new ArrayCollection();
|
$this->prices = new ArrayCollection();
|
||||||
$this->prices = new ArrayCollection();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,32 +411,22 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, CarrierAddress> */
|
|
||||||
#[Groups(['carrier:item:read'])]
|
#[Groups(['carrier:item:read'])]
|
||||||
public function getAddresses(): Collection
|
public function getAddress(): ?CarrierAddress
|
||||||
{
|
{
|
||||||
return $this->addresses;
|
return $this->address;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addAddress(CarrierAddress $address): static
|
public function setAddress(?CarrierAddress $address): static
|
||||||
{
|
{
|
||||||
if (!$this->addresses->contains($address)) {
|
$this->address = $address;
|
||||||
$this->addresses->add($address);
|
if (null !== $address && $address->getCarrier() !== $this) {
|
||||||
$address->setCarrier($this);
|
$address->setCarrier($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeAddress(CarrierAddress $address): static
|
|
||||||
{
|
|
||||||
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
|
|
||||||
$address->setCarrier(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<int, CarrierContact> */
|
/** @return Collection<int, CarrierContact> */
|
||||||
#[Groups(['carrier:item:read'])]
|
#[Groups(['carrier:item:read'])]
|
||||||
public function getContacts(): Collection
|
public function getContacts(): Collection
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
|
* Adresse d'un transporteur (1:1, OneToOne — decision metier ERP-172) — onglet
|
||||||
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
|
* Adresse (M4). Jumelle de SupplierAddress (M2), version simplifiee (pas de type
|
||||||
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
|
* d'adresse, pas de M2M sites/categories sur l'adresse : les sites du M4 vivent
|
||||||
|
* dans l'onglet Prix), et UNIQUE par transporteur (la jumelle M2 est 1:n).
|
||||||
*
|
*
|
||||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||||
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
||||||
@@ -30,9 +31,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
|
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
|
||||||
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
|
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
|
||||||
* l'onglet Prix) :
|
* l'onglet Prix) :
|
||||||
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
|
* - POST /api/carriers/{carrierId}/address : creation rattachee au
|
||||||
* transporteur parent (Link toProperty 'carrier'), security
|
* transporteur parent (Link toProperty 'carrier'), security
|
||||||
* transport.carriers.manage.
|
* transport.carriers.manage. 409 si le transporteur a deja une adresse
|
||||||
|
* (CarrierAddressProcessor::guardSingleAddress, avant la contrainte d'unicite).
|
||||||
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
||||||
* transport.carriers.manage.
|
* transport.carriers.manage.
|
||||||
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
||||||
@@ -58,14 +60,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
uriTemplate: '/carriers/{carrierId}/addresses',
|
uriTemplate: '/carriers/{carrierId}/address',
|
||||||
uriVariables: [
|
uriVariables: [
|
||||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||||
],
|
],
|
||||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
// read:false : pas de stade lecture du parent. Le parent est rattache
|
||||||
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
|
// manuellement par CarrierAddressProcessor::linkParent (404 si absent),
|
||||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
// qui refuse aussi une 2e adresse (RG metier : adresse UNIQUE — 409).
|
||||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
|
|
||||||
read: false,
|
read: false,
|
||||||
security: "is_granted('transport.carriers.manage')",
|
security: "is_granted('transport.carriers.manage')",
|
||||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||||
@@ -86,7 +87,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table(name: 'carrier_address')]
|
#[ORM\Table(name: 'carrier_address')]
|
||||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
// Adresse UNIQUE par transporteur (OneToOne owning side) : contrainte d'unicite
|
||||||
|
// sur carrier_id (decision metier ERP-172).
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_carrier_address_carrier', columns: ['carrier_id'])]
|
||||||
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
|
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
|
||||||
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
|
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
@@ -100,7 +103,7 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['carrier:item:read'])]
|
#[Groups(['carrier:item:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
|
#[ORM\OneToOne(targetEntity: Carrier::class, inversedBy: 'address')]
|
||||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||||
private ?Carrier $carrier = null;
|
private ?Carrier $carrier = null;
|
||||||
|
|
||||||
@@ -133,9 +136,6 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||||
private ?string $streetComplement = null;
|
private ?string $streetComplement = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
|
||||||
private int $position = 0;
|
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -212,16 +212,4 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPosition(): int
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPosition(int $position): static
|
|
||||||
{
|
|
||||||
$this->position = $position;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -6,12 +6,14 @@ namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use ApiPlatform\Validator\Exception\ValidationException;
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
use App\Module\Transport\Domain\Entity\Carrier;
|
use App\Module\Transport\Domain\Entity\Carrier;
|
||||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Validator\ConstraintViolation;
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
use Symfony\Component\Validator\ConstraintViolationList;
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
@@ -63,6 +65,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->linkParent($data, $uriVariables);
|
$this->linkParent($data, $uriVariables);
|
||||||
|
$this->guardSingleAddress($data, $operation);
|
||||||
$this->guardCharteredAddress($data);
|
$this->guardCharteredAddress($data);
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
@@ -70,7 +73,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
* (/carriers/{carrierId}/address) : la relation n'est pas peuplee
|
||||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||||
*/
|
*/
|
||||||
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
||||||
@@ -98,6 +101,29 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
|||||||
$address->setCarrier($carrier);
|
$address->setCarrier($carrier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresse UNIQUE par transporteur (decision metier ERP-172) : un POST sur un
|
||||||
|
* transporteur qui a deja une adresse -> 409 explicite (plutot qu'un 500 sur la
|
||||||
|
* contrainte d'unicite carrier_id). No-op sur PATCH (mise a jour de l'adresse
|
||||||
|
* existante). Lookup repository pour rester robuste a la synchro bidirectionnelle.
|
||||||
|
*/
|
||||||
|
private function guardSingleAddress(CarrierAddress $address, Operation $operation): void
|
||||||
|
{
|
||||||
|
if (!$operation instanceof Post) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$carrier = $address->getCarrier();
|
||||||
|
if (!$carrier instanceof Carrier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->em->getRepository(CarrierAddress::class)->findOneBy(['carrier' => $carrier]);
|
||||||
|
if (null !== $existing && $existing->getId() !== $address->getId()) {
|
||||||
|
throw new ConflictHttpException('Ce transporteur a déjà une adresse.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
$address->setPostalCode($postalCode);
|
$address->setPostalCode($postalCode);
|
||||||
$address->setCity($city);
|
$address->setCity($city);
|
||||||
$address->setStreet($street);
|
$address->setStreet($street);
|
||||||
$carrier->addAddress($address);
|
// Adresse UNIQUE (OneToOne) — ERP-172.
|
||||||
|
$carrier->setAddress($address);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,8 +75,12 @@ class UploadedDocument
|
|||||||
#[Groups(['uploaded_document:read'])]
|
#[Groups(['uploaded_document:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// `uploaded_document:reference` : groupe minimal d'EMBARQUEMENT (nom de fichier
|
||||||
|
// seul, sans `storedPath`/`checksum`) pour qu'une entite parente (ex: Carrier)
|
||||||
|
// affiche le libelle du document au lieu d'un simple IRI. La parente l'ajoute a
|
||||||
|
// son `normalizationContext`.
|
||||||
#[ORM\Column(name: 'original_filename', length: 255)]
|
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||||
#[Groups(['uploaded_document:read'])]
|
#[Groups(['uploaded_document:read', 'uploaded_document:reference'])]
|
||||||
private string $originalFilename;
|
private string $originalFilename;
|
||||||
|
|
||||||
#[ORM\Column(name: 'stored_path', length: 512)]
|
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||||
|
|||||||
@@ -497,15 +497,14 @@ final class ColumnCommentsCatalog
|
|||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'carrier_address' => [
|
'carrier_address' => [
|
||||||
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
|
'_table' => 'Adresse d un transporteur (1:1, OneToOne — ERP-172 : adresse UNIQUE) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.',
|
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE, UNIQUE (uniq_carrier_address_carrier) — transporteur proprietaire de l unique adresse.',
|
||||||
'country' => 'Pays de l adresse — defaut France.',
|
'country' => 'Pays de l adresse — defaut France.',
|
||||||
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
|
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
|
||||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
||||||
'street' => 'Numero et voie de l adresse.',
|
'street' => 'Numero et voie de l adresse.',
|
||||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||||
'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).',
|
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|
||||||
'carrier_contact' => [
|
'carrier_contact' => [
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
|||||||
$address->setPostalCode('86000');
|
$address->setPostalCode('86000');
|
||||||
$address->setCity('Poitiers');
|
$address->setCity('Poitiers');
|
||||||
$address->setStreet('12 rue des Acacias');
|
$address->setStreet('12 rue des Acacias');
|
||||||
$carrier->addAddress($address);
|
$carrier->setAddress($address);
|
||||||
$em->persist($address);
|
$em->persist($address);
|
||||||
|
|
||||||
$contact = new CarrierContact();
|
$contact = new CarrierContact();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Output\NullOutput;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
||||||
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
|
* POST /api/carriers/{id}/address, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||||
*
|
*
|
||||||
* Contrat verifie :
|
* Contrat verifie :
|
||||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||||
@@ -55,7 +55,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||||
]);
|
]);
|
||||||
@@ -73,7 +73,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'postalCode' => '86000', // Poitiers
|
'postalCode' => '86000', // Poitiers
|
||||||
@@ -91,7 +91,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => ['postalCode' => '86000'],
|
'json' => ['postalCode' => '86000'],
|
||||||
]);
|
]);
|
||||||
@@ -107,7 +107,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => [
|
'json' => [
|
||||||
'country' => 'France',
|
'country' => 'France',
|
||||||
@@ -119,13 +119,30 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(201);
|
self::assertResponseStatusCodeSame(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSecondAddressReturns409(): void
|
||||||
|
{
|
||||||
|
// Adresse UNIQUE (ERP-172) : un 2e POST sur un transporteur qui a deja une
|
||||||
|
// adresse -> 409 explicite (garde CarrierAddressProcessor avant la contrainte
|
||||||
|
// d'unicite carrier_id).
|
||||||
|
$address = $this->seedAddress('Deja Une Adresse', false);
|
||||||
|
$carrier = $address->getCarrier();
|
||||||
|
self::assertNotNull($carrier);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
|
'headers' => ['Content-Type' => self::LD],
|
||||||
|
'json' => ['postalCode' => '17000', 'city' => 'La Rochelle', 'street' => '2 rue Neuve'],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(409);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPostAddressOnUnknownCarrierReturns404(): void
|
public function testPostAddressOnUnknownCarrierReturns404(): void
|
||||||
{
|
{
|
||||||
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
|
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
|
||||||
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
|
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
$client->request('POST', '/api/carriers/999999/addresses', [
|
$client->request('POST', '/api/carriers/999999/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||||
]);
|
]);
|
||||||
@@ -156,7 +173,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
self::assertNotNull($carrier);
|
self::assertNotNull($carrier);
|
||||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||||
|
|
||||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||||
'headers' => ['Content-Type' => self::LD],
|
'headers' => ['Content-Type' => self::LD],
|
||||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||||
]);
|
]);
|
||||||
@@ -201,7 +218,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
|||||||
$address->setPostalCode('86000');
|
$address->setPostalCode('86000');
|
||||||
$address->setCity('Poitiers');
|
$address->setCity('Poitiers');
|
||||||
$address->setStreet('12 rue des Acacias');
|
$address->setStreet('12 rue des Acacias');
|
||||||
$carrier->addAddress($address);
|
$carrier->setAddress($address);
|
||||||
$em->persist($address);
|
$em->persist($address);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Transport\Api;
|
namespace App\Tests\Module\Transport\Api;
|
||||||
|
|
||||||
|
use App\Module\Transport\Domain\Entity\Carrier;
|
||||||
|
use App\Shared\Domain\Entity\UploadedDocument;
|
||||||
|
use App\Tests\Module\Commercial\Api\SupplierSerializationContractTest;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
|
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
|
||||||
* § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}.
|
* § 4.0 / § 4.0.bis). Jumeau de {@see SupplierSerializationContractTest}.
|
||||||
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
|
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
|
||||||
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
|
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
|
||||||
* detail prices[].client / .supplier / .departureSite / .deliverySite.
|
* detail prices[].client / .supplier / .departureSite / .deliverySite.
|
||||||
@@ -88,8 +93,9 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
|||||||
self::assertArrayHasKey('isChartered', $data);
|
self::assertArrayHasKey('isChartered', $data);
|
||||||
self::assertFalse($data['isArchived']);
|
self::assertFalse($data['isArchived']);
|
||||||
|
|
||||||
self::assertNotEmpty($data['addresses']);
|
// Adresse UNIQUE (OneToOne, ERP-172) : embarquee en OBJET (pas une liste).
|
||||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
self::assertIsArray($data['address']);
|
||||||
|
self::assertSame('Poitiers', $data['address']['city']);
|
||||||
|
|
||||||
self::assertNotEmpty($data['contacts']);
|
self::assertNotEmpty($data['contacts']);
|
||||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||||
@@ -133,6 +139,43 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
|||||||
self::assertIsArray($supplierPrice['deliverySite']);
|
self::assertIsArray($supplierPrice['deliverySite']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Decharge (RG-4.02) embarquee en OBJET avec son nom de fichier (ERP-171) ===
|
||||||
|
|
||||||
|
public function testDetailEmbedsDischargeDocumentFilename(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
// Decharge (UploadedDocument) rattachee a un transporteur certifie AUTRE.
|
||||||
|
$document = new UploadedDocument(
|
||||||
|
originalFilename: 'decharge-test.pdf',
|
||||||
|
storedPath: '2026/06/'.bin2hex(random_bytes(8)).'.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
sizeBytes: 1234,
|
||||||
|
checksum: hash('sha256', 'contenu'),
|
||||||
|
createdAt: new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
$em->persist($document);
|
||||||
|
|
||||||
|
$carrier = new Carrier();
|
||||||
|
$carrier->setName('AUTRE DISCHARGE CO');
|
||||||
|
$carrier->setCertificationType('AUTRE');
|
||||||
|
$carrier->setDischargeDocument($document);
|
||||||
|
$em->persist($carrier);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
// dischargeDocument embarque en OBJET (uploaded_document:reference) avec son
|
||||||
|
// nom de fichier — sinon le front n'a qu'un IRI nu et affiche un champ vide.
|
||||||
|
self::assertArrayHasKey('dischargeDocument', $data);
|
||||||
|
self::assertIsArray($data['dischargeDocument'], 'dischargeDocument doit etre un objet embarque, pas un IRI nu.');
|
||||||
|
self::assertSame('decharge-test.pdf', $data['dischargeDocument']['originalFilename']);
|
||||||
|
// Le groupe minimal n'expose PAS les metadonnees internes (storedPath / checksum).
|
||||||
|
self::assertArrayNotHasKey('storedPath', $data['dischargeDocument']);
|
||||||
|
self::assertArrayNotHasKey('checksum', $data['dischargeDocument']);
|
||||||
|
}
|
||||||
|
|
||||||
// === RBAC : 403 sans la permission view ===
|
// === RBAC : 403 sans la permission view ===
|
||||||
|
|
||||||
public function testForbiddenWithoutViewPermission(): void
|
public function testForbiddenWithoutViewPermission(): void
|
||||||
@@ -167,7 +210,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
|||||||
|
|
||||||
self::assertArrayHasKey('member', $list);
|
self::assertArrayHasKey('member', $list);
|
||||||
self::assertArrayHasKey('qualimatCarrier', $detail);
|
self::assertArrayHasKey('qualimatCarrier', $detail);
|
||||||
self::assertArrayHasKey('addresses', $detail);
|
self::assertArrayHasKey('address', $detail);
|
||||||
self::assertArrayHasKey('contacts', $detail);
|
self::assertArrayHasKey('contacts', $detail);
|
||||||
self::assertArrayHasKey('prices', $detail);
|
self::assertArrayHasKey('prices', $detail);
|
||||||
|
|
||||||
@@ -183,7 +226,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
|||||||
*
|
*
|
||||||
* @param array<string, mixed> $collection
|
* @param array<string, mixed> $collection
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>|null
|
* @return null|array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function memberById(array $collection, int $id): ?array
|
private function memberById(array $collection, int $id): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user