feat(front) : branchement site courant + formats d'affichage des tickets de pesée (ERP-191)

This commit is contained in:
2026-06-22 16:13:30 +02:00
parent b438838465
commit 4dcc247436
4 changed files with 152 additions and 33 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
@@ -9,6 +9,7 @@ const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockReset = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
@@ -17,6 +18,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
const currentSiteRef = ref<{ id: number } | null>(null)
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
@@ -40,6 +44,7 @@ vi.stubGlobal('useWeighingTicketsRepository', () => ({
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: vi.fn(),
reset: mockReset,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
@@ -86,8 +91,13 @@ const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
// d'un test à l'autre et se déclencheraient en double.
const mountedWrappers: VueWrapper[] = []
function mountPage() {
return mount(WeighingTicketsIndex, {
const wrapper = mount(WeighingTicketsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
@@ -96,6 +106,8 @@ function mountPage() {
},
},
})
mountedWrappers.push(wrapper)
return wrapper
}
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
@@ -104,8 +116,17 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
mockApiGet.mockReset().mockResolvedValue(new Blob())
mockCan.mockReset().mockReturnValue(true)
mockFetch.mockReset()
mockReset.mockReset()
mockToastError.mockReset()
capturedRows.value = []
currentSiteRef.value = null
})
afterEach(() => {
// Démonte les composants montés → libère leurs watchers (site courant).
while (mountedWrappers.length > 0) {
mountedWrappers.pop()?.unmount()
}
})
it('charge la liste au montage', async () => {
@@ -114,6 +135,17 @@ describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
expect(mockFetch).toHaveBeenCalled()
})
it('recharge la liste (page 1) quand le site courant change', async () => {
mountPage()
await flushPromises()
expect(mockReset).not.toHaveBeenCalled()
// Simule un switch de site via le switcher global.
currentSiteRef.value = { id: 2 }
await flushPromises()
expect(mockReset).toHaveBeenCalledTimes(1)
})
it('formate la date au format JJ-MM-AAAA', async () => {
const wrapper = mountPage()
await flushPromises()
@@ -45,13 +45,18 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
// Site courant (switcher global) : la liste est cloisonnée par site côté back
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
// contente de recharger quand le site change pour refléter le bon périmètre.
const { currentSite } = useCurrentSite()
useHead({ title: t('logistique.weighingTickets.title') })
@@ -70,6 +75,7 @@ const {
fetch: loadTickets,
goToPage,
setItemsPerPage,
reset: reloadFromFirstPage,
} = useWeighingTicketsRepository()
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
@@ -83,7 +89,7 @@ const rows = computed(() => tickets.value.map(ticket => ({
supplier: ticket.supplier?.companyName ?? '',
otherLabel: ticket.otherLabel ?? '',
displayDate: formatDateFr(ticket.displayDate),
netWeight: formatWeight(ticket.netWeight),
netWeight: formatWeightKg(ticket.netWeight),
})))
const columns = [
@@ -95,34 +101,6 @@ const columns = [
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
]
/** Format court francais JJ-MM-AAAA (spec M5). 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()}`
}
/**
* Poids net affiche en kg avec separateur de milliers (espace) + suffixe « Kg »
* (spec-front § formatage : « 7 150 Kg »). Chaine vide si poids absent (ticket
* dont la pesee a plein n'est pas encore finalisee). Groupement manuel (espace
* ASCII) pour un rendu deterministe, independant de l'ICU de l'environnement.
*/
function formatWeight(value: number | null | undefined): string {
if (value === null || value === undefined) {
return ''
}
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return `${grouped} Kg`
}
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/weighing-tickets/${item.id}/edit`)
@@ -175,5 +153,16 @@ function triggerDownload(blob: Blob, filename: string): void {
URL.revokeObjectURL(url)
}
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
// changement.
watch(() => currentSite.value?.id, (id, previousId) => {
if (id !== previousId) {
reloadFromFirstPage()
}
})
onMounted(loadTickets)
</script>
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
describe('weighingTicketFormat', () => {
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
describe('formatDateFr', () => {
it('formate un datetime ISO en JJ-MM-AAAA', () => {
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
})
it('zéro-pad le jour et le mois', () => {
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
})
it('retourne une chaîne vide si absente ou invalide', () => {
expect(formatDateFr(null)).toBe('')
expect(formatDateFr(undefined)).toBe('')
expect(formatDateFr('pas-une-date')).toBe('')
})
})
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
describe('formatWeightKg', () => {
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
expect(formatWeightKg(7150)).toBe('7 150 Kg')
expect(formatWeightKg(14300)).toBe('14 300 Kg')
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
})
it('gère les petits nombres sans séparateur', () => {
expect(formatWeightKg(0)).toBe('0 Kg')
expect(formatWeightKg(999)).toBe('999 Kg')
})
it('retourne une chaîne vide si le poids est absent', () => {
expect(formatWeightKg(null)).toBe('')
expect(formatWeightKg(undefined)).toBe('')
})
})
// ── Immatriculation UPPER ─────────────────────────────────────────────────
describe('formatPlate', () => {
it('met en majuscules et trim', () => {
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
})
it('retourne une chaîne vide si absente', () => {
expect(formatPlate(null)).toBe('')
expect(formatPlate('')).toBe('')
})
})
})
@@ -0,0 +1,46 @@
/**
* Filtres d'affichage du module « Tickets de pesée » (M5, ERP-191). Helpers PURS
* et testables, partagés par la liste et les écrans. Le serveur reste l'autorité
* de normalisation (spec-front § Règles de formatage) : ces helpers ne font que
* mettre en forme la valeur déjà normalisée renvoyée par l'API.
*/
/**
* Date courte française `JJ-MM-AAAA` (spec M5). Chaîne vide si la valeur est
* absente ou invalide. Lit les composantes locales (cohérent avec l'affichage
* des autres répertoires M1→M4).
*/
export 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()}`
}
/**
* Poids en kg avec séparateur de milliers (espace) + suffixe « Kg »
* (spec-front : « 7 150 Kg »). Chaîne vide si le poids est absent (ticket dont la
* pesée à plein n'est pas finalisée). Groupement manuel (espace ASCII) pour un
* rendu déterministe, indépendant de l'ICU de l'environnement.
*/
export function formatWeightKg(value: number | null | undefined): string {
if (value === null || value === undefined) {
return ''
}
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return `${grouped} Kg`
}
/**
* Immatriculation en MAJUSCULES (cohérent avec la normalisation serveur RG-5.01 :
* trim + UPPER). Chaîne vide si absente.
*/
export function formatPlate(value: string | null | undefined): string {
return value ? value.trim().toUpperCase() : ''
}