fix(logistique) : bon de pesée — cartouche tiers + filtrage des listes contrepartie par site (ERP-208)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m1s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m38s

- PDF : cartouche bordé en haut à droite avec le type (Client/Fournisseur/Autre) et le nom du tiers (getCounterpartyName + getCounterpartyTypeLabel).
- Écran ticket : listes Client/Fournisseur filtrées sur le site courant (param siteId[]) et rechargées au changement de site ; reset du tiers sélectionné s'il sort du périmètre du nouveau site.
This commit is contained in:
2026-06-25 14:09:33 +02:00
parent fdd4394e99
commit 527e47d822
11 changed files with 712 additions and 19 deletions
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useWeighingTicketReferentials } from '../useWeighingTicketReferentials'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests des référentiels Client/Fournisseur de l'écran ticket de pesée (M5).
* Contrat couvert (ERP-208) : `load(siteId)` filtre les deux endpoints par site
* courant via `siteId[]` ; sans site → listes complètes (param absent).
*/
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
mockApiGet.mockReset()
mockApiGet.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site courant est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = mockApiGet.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false' })
})
it('mappe les membres Hydra en options { value: @id, label: companyName }', async () => {
mockApiGet.mockResolvedValue({ member: [{ '@id': '/api/clients/3', companyName: 'ACME' }] })
const { load, clients } = useWeighingTicketReferentials()
await load(7)
expect(clients.value).toEqual([{ value: '/api/clients/3', label: 'ACME' }])
})
})
@@ -32,11 +32,19 @@ export function useWeighingTicketReferentials() {
const clients = ref<RefOption[]>([])
const suppliers = ref<RefOption[]>([])
/** Récupère une collection complète (pagination désactivée) en Hydra. */
async function fetchAll(url: string): Promise<PartyMember[]> {
/**
* Récupère une collection complète (pagination désactivée) en Hydra. Filtre par
* site courant si `siteId` est fourni (ERP-208) : un tiers est rattaché à un site
* via les sites de ses adresses — param `siteId[]` déjà géré par les providers M1/M2.
*/
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>(
url,
{ pagination: 'false' },
query,
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
@@ -45,14 +53,15 @@ export function useWeighingTicketReferentials() {
/**
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
* faire échouer l'autre).
* faire échouer l'autre). `siteId` (site courant) filtre les listes par site
* (ERP-208) ; absent → listes complètes.
*/
async function load(): Promise<void> {
async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([
fetchAll('/clients').then((list) => {
fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers').then((list) => {
fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
@@ -28,6 +28,8 @@ vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
// Site courant (ERP-208) : nécessaire depuis que l'écran filtre les référentiels par site.
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
@@ -7,9 +7,10 @@ const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
const mockRefLoad = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
@@ -23,6 +24,8 @@ vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
// Site courant (ERP-208) : id 7 → les référentiels doivent être chargés filtrés sur ce site.
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
globalThis.open = mockOpen
const NewPage = (await import('../weighing-tickets/new.vue')).default
@@ -70,6 +73,12 @@ describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
mockRefLoad.mockReset().mockResolvedValue(undefined)
})
it('charge les référentiels filtrés sur le site courant au montage (ERP-208)', async () => {
await mountPage()
expect(mockRefLoad).toHaveBeenCalledWith(7)
})
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
@@ -186,7 +186,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
@@ -404,8 +404,24 @@ function printTicket(): void {
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
}
const { currentSite } = useCurrentSite()
/**
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
*/
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(async () => {
referentials.load().catch(() => {})
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
@@ -418,4 +434,9 @@ onMounted(async () => {
loading.value = false
}
})
// Changement de site pendant l'édition → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
</script>
@@ -176,7 +176,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
@@ -375,7 +375,28 @@ async function submitValidate(): Promise<void> {
}
}
const { currentSite } = useCurrentSite()
/**
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
*/
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => {
referentials.load().catch(() => {})
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
</script>