fix(transport) : tableau prix — regroupement et tri par adresse de livraison, contenant par ligne (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

This commit is contained in:
2026-06-19 11:17:47 +02:00
parent 9a42c432f8
commit cdd43960cd
@@ -173,28 +173,21 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="(group, gi) in priceGroups" :key="group.label"> <template v-for="(group, gi) in priceGroups" :key="gi">
<tr <tr
v-for="(row, i) in group.rows" v-for="(row, i) in group.rows"
:key="`${gi}-${i}`" :key="`${gi}-${i}`"
> >
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ; <!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur
séparateur épais en bas entre les groupes (sauf dernier). --> à droite, comme l'ancienne colonne de groupe. -->
<td <td class="border-r border-black px-3 py-4 text-center align-middle text-[14px] font-medium" :class="dataBorder(gi, i)">{{ row.transport }}</td>
v-if="i === 0" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td>
:rowspan="group.rows.length" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td>
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td>
:class="groupBorder(gi)" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.forfait }}</td>
> <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.tonne }}</td>
{{ group.label }} <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.indexation }}</td>
</td> <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.party }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
</tr> </tr>
</template> </template>
<tr v-if="!hasPrices"> <tr v-if="!hasPrices">
@@ -343,10 +336,15 @@ function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : [] return country ? [{ value: country, label: country }] : []
} }
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ─── // ── Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON) ─────────────
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const // Rang d'affichage des contenants au sein d'une même adresse (Fond mouvant puis Benne).
const CONTAINER_RANK: Record<string, number> = { FOND_MOUVANT: 0, BENNE: 1 }
interface PriceRowView { interface PriceRowView {
/** Contenant (libellé affiché : Fond mouvant / Benne). */
transport: string
/** Contenant brut (FOND_MOUVANT / BENNE) — tri interne du groupe. */
transportType: string
/** Fournisseur ou client lié au prix (raison sociale). */ /** Fournisseur ou client lié au prix (raison sociale). */
party: string party: string
apro: string apro: string
@@ -357,9 +355,8 @@ interface PriceRowView {
state: string state: string
} }
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */ /** Groupe de prix d'une même adresse de livraison (lignes consécutives). */
interface PriceGroupView { interface PriceGroupView {
label: string
rows: PriceRowView[] rows: PriceRowView[]
} }
@@ -386,6 +383,7 @@ function siteCode(relation: Relation): string {
/** /**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) : * Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Transport » = le contenant (Fond mouvant / Benne) ;
* - « Fournisseurs / Clients » = le fournisseur OU le client lié (raison sociale) ; * - « Fournisseurs / Clients » = le fournisseur OU le client lié (raison sociale) ;
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ; * - « 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 ;
@@ -393,7 +391,10 @@ function siteCode(relation: Relation): string {
*/ */
function toPriceRow(price: CarrierPriceRead): PriceRowView { function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT' const isClient = price.direction === 'CLIENT'
const containerType = price.containerType ?? ''
return { return {
transportType: containerType,
transport: containerType ? t(`transport.carriers.containerType.${containerType}`) : '',
party: labelOfRelation(isClient ? price.client : price.supplier), party: labelOfRelation(isClient ? price.client : price.supplier),
apro: isClient ? siteCode(price.departureSite) : siteCode(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),
@@ -413,28 +414,40 @@ function stateSuffix(state: string): string {
return map[state] ?? '' return map[state] ?? ''
} }
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée // Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont
// par groupe (rowspan) à gauche, conformément à la maquette. // consécutives (triées par contenant Fond mouvant → Benne), les groupes triés
// alphabétiquement par adresse. Un séparateur épais sépare deux adresses.
const priceGroups = computed<PriceGroupView[]>(() => { const priceGroups = computed<PriceGroupView[]>(() => {
const list = carrier.value?.prices ?? [] const rows = (carrier.value?.prices ?? []).map(toPriceRow)
return PRICE_GROUP_ORDER const byDelivery = new Map<string, PriceRowView[]>()
.map(container => ({ for (const row of rows) {
label: t(`transport.carriers.containerType.${container}`), const list = byDelivery.get(row.delivery)
rows: list.filter(p => p.containerType === container).map(toPriceRow), if (list) {
list.push(row)
} else {
byDelivery.set(row.delivery, [row])
}
}
return [...byDelivery.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'fr'))
.map(([, groupRows]) => ({
rows: groupRows
.slice()
.sort((x, y) => (CONTAINER_RANK[x.transportType] ?? 99) - (CONTAINER_RANK[y.transportType] ?? 99)),
})) }))
.filter(group => group.rows.length > 0)
}) })
const hasPrices = computed(() => priceGroups.value.length > 0) const hasPrices = computed(() => priceGroups.value.length > 0)
/** /**
* Bordure basse d'une cellule de données : * Bordure basse d'une cellule de données :
* - ligne interne d'un groupe → fine grise ; * - ligne interne d'un groupe d'adresse → fine grise ;
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ; * - dernière ligne d'un groupe NON final → épaisse noire (sépare deux adresses) ;
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge, * - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas). * évite la double bordure tout en bas).
*/ */
function dataBorder(group: PriceGroupView, i: number, gi: number): string { function dataBorder(gi: number, i: number): string {
const group = priceGroups.value[gi]
const isLastRow = i === group.rows.length - 1 const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1 const isLastGroup = gi === priceGroups.value.length - 1
if (!isLastRow) { if (!isLastRow) {
@@ -443,11 +456,6 @@ function dataBorder(group: PriceGroupView, i: number, gi: number): string {
return isLastGroup ? '' : 'border-b-2 border-black' return isLastGroup ? '' : 'border-b-2 border-black'
} }
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
function groupBorder(gi: number): string {
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
}
// ── Export XLSX des prix ───────────────────────────────────────────────────── // ── Export XLSX des prix ─────────────────────────────────────────────────────
const exporting = ref(false) const exporting = ref(false)