feat(front) : écran ajouter un ticket de pesée (blocs vide/plein, pesée, masque immat) (ERP-189)

This commit is contained in:
2026-06-22 15:11:54 +02:00
parent ef7bf69980
commit 9f3fe4da4e
8 changed files with 1023 additions and 0 deletions
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
const mockPost = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ post: mockPost }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
const { useWeighbridge } = await import('../useWeighbridge')
describe('useWeighbridge', () => {
beforeEach(() => {
mockPost.mockReset()
})
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
const { triggerAuto } = useWeighbridge()
const reading = await triggerAuto()
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'AUTO' },
expect.objectContaining({ toast: false }),
)
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
})
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' })
const { triggerManual } = useWeighbridge()
const reading = await triggerManual(5000, 'PAP-555')
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' },
expect.objectContaining({ toast: false }),
)
expect(reading.dsd).toBe(43)
})
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
const { extractWeighbridgeError } = useWeighbridge()
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
})
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
const { extractWeighbridgeError } = useWeighbridge()
expect(extractWeighbridgeError(new Error('network')))
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
})
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
mockPost.mockRejectedValue({ response: { status: 503 } })
const { triggerAuto } = useWeighbridge()
await expect(triggerAuto()).rejects.toBeDefined()
})
})
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest'
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe.
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' }))
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
describe('useWeighingTicketForm', () => {
it('initialise les 2 blocs à la date du jour (RG-5.07), sans poids ni DSD', () => {
const form = useWeighingTicketForm()
expect(form.empty.date).toBe('2026-06-22')
expect(form.full.date).toBe('2026-06-22')
expect(form.empty.weight).toBeNull()
expect(form.empty.dsd).toBeNull()
expect(form.counterpartyType.value).toBeNull()
})
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
const form = useWeighingTicketForm()
form.supplierIri.value = '/api/suppliers/3'
form.otherLabel.value = 'Particulier'
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/629'
expect(form.counterpartyField.value).toBe('client')
expect(form.supplierIri.value).toBeNull()
expect(form.otherLabel.value).toBeNull()
const payload = form.buildCreatePayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload).not.toHaveProperty('supplier')
expect(payload).not.toHaveProperty('otherLabel')
})
it('FOURNISSEUR : ne conserve que le supplier', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('FOURNISSEUR')
form.supplierIri.value = '/api/suppliers/7'
expect(form.counterpartyField.value).toBe('supplier')
expect(form.clientIri.value).toBeNull()
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
})
it('AUTRE : ne conserve que le libellé libre', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('AUTRE')
form.otherLabel.value = 'Reprise interne'
expect(form.counterpartyField.value).toBe('other')
expect(form.clientIri.value).toBeNull()
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
})
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
const form = useWeighingTicketForm()
form.immatriculation.value = 'AB-123-CD'
form.plateFreeFormat.value = true
// Les 2 payloads (création + finalisation) reflètent la même valeur.
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
expect(form.buildFullPayload().plateFreeFormat).toBe(true)
})
// ── Application d'une lecture de pesée ────────────────────────────────────
it('applyReading remplit poids / DSD / mode du bloc visé', () => {
const form = useWeighingTicketForm()
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
expect(form.empty.weight).toBe(7150)
expect(form.empty.dsd).toBe(1)
expect(form.empty.mode).toBe('AUTO')
expect(form.empty.manualNumber).toBeNull()
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' })
expect(form.full.weight).toBe(14300)
expect(form.full.manualNumber).toBe('PAP-555')
})
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
const form = useWeighingTicketForm()
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/1'
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
const create = form.buildCreatePayload()
expect(create.emptyWeight).toBe(7150)
expect(create.emptyDsd).toBe(1)
expect(create.emptyMode).toBe('AUTO')
expect(create).not.toHaveProperty('fullWeight')
const full = form.buildFullPayload()
expect(full.fullWeight).toBe(14300)
expect(full.fullDsd).toBe(2)
expect(full.fullMode).toBe('AUTO')
})
})
@@ -0,0 +1,74 @@
/**
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
* n'existe pas encore quand on pèse depuis le formulaire principal.
*
* Deux modes :
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur
* calcule le DSD = dernier + 1 (RG-5.04).
*
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
*/
/** Mode de pesée — miroir de l'enum back. */
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
export interface WeighbridgeReading {
weight: number
dsd: number
mode: WeighbridgeMode
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
manualNumber?: string
}
export function useWeighbridge() {
const api = useApi()
const { t } = useI18n()
/**
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
* en toast global.
*/
async function triggerAuto(): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'AUTO' },
{ toast: false },
)
}
/**
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ;
* le `manualNumber` est la référence du ticket papier / autre bascule.
*/
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'MANUAL', weight, manualNumber },
{ toast: false },
)
}
/**
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
* générique invitant à la pesée manuelle.
*/
function extractWeighbridgeError(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data as
| { detail?: string, title?: string }
| undefined
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
}
return { triggerAuto, triggerManual, extractWeighbridgeError }
}
@@ -0,0 +1,178 @@
import { computed, reactive, ref } from 'vue'
import { todayIso } from '~/shared/utils/date'
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
/**
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
* à plein — qui partagent un même véhicule.
*
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
* Changer de type purge les champs des autres types — aucune donnée fantôme.
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
* les 2 blocs bindent la même ref.
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
*
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
*/
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
/** Saisie d'une pesée (bloc vide OU bloc plein). */
export interface WeighingBlockState {
/** Date de la pesée (ISO `YYYY-MM-DD`) — jour par défaut (RG-5.07). */
date: string | null
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
weight: number | null
/** DSD — readonly, rempli par la pesée (RG-5.04). */
dsd: number | null
/** Mode de la dernière pesée appliquée au bloc. */
mode: WeighbridgeMode | null
/** Numéro de pesée (rempli uniquement en pesée manuelle). */
manualNumber: string | null
}
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
function emptyBlock(today: string): WeighingBlockState {
return {
date: today,
weight: null,
dsd: null,
mode: null,
manualNumber: null,
}
}
export function useWeighingTicketForm() {
const today = todayIso()
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
const counterpartyType = ref<CounterpartyType | null>(null)
const clientIri = ref<string | null>(null)
const supplierIri = ref<string | null>(null)
const otherLabel = ref<string | null>(null)
/**
* Change le type de contrepartie et purge les champs devenus hors-sujet :
* un seul de client / supplier / otherLabel est conservé selon le type
* (RG-5.03 — pas de FK fantôme envoyée au back).
*/
function setCounterpartyType(type: CounterpartyType | null): void {
counterpartyType.value = type
if (type !== 'CLIENT') clientIri.value = null
if (type !== 'FOURNISSEUR') supplierIri.value = null
if (type !== 'AUTRE') otherLabel.value = null
}
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
const immatriculation = ref<string | null>(null)
const plateFreeFormat = ref<boolean>(false)
// ── Les deux pesées ───────────────────────────────────────────────────────
const empty = reactive<WeighingBlockState>(emptyBlock(today))
const full = reactive<WeighingBlockState>(emptyBlock(today))
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
const ticketId = ref<number | null>(null)
/**
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
* pour afficher conditionnellement le bon champ (RG-5.03).
*/
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
switch (counterpartyType.value) {
case 'CLIENT': return 'client'
case 'FOURNISSEUR': return 'supplier'
case 'AUTRE': return 'other'
default: return null
}
})
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
function applyReading(
block: WeighingBlockState,
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string },
): void {
block.weight = reading.weight
block.dsd = reading.dsd
block.mode = reading.mode
block.manualNumber = reading.manualNumber ?? null
}
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
function counterpartyPayload(): Record<string, unknown> {
switch (counterpartyType.value) {
case 'CLIENT': return { client: clientIri.value }
case 'FOURNISSEUR': return { supplier: supplierIri.value }
case 'AUTRE': return { otherLabel: otherLabel.value || null }
default: return {}
}
}
/**
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
* pour que `useFormErrors` mappe les 422 inline.
*/
function buildCreatePayload(): Record<string, unknown> {
return {
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
emptyDate: empty.date || null,
emptyWeight: empty.weight,
emptyDsd: empty.dsd,
emptyMode: empty.mode,
emptyManualNumber: empty.manualNumber || null,
}
}
/**
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
* les 2 blocs → on le repousse aussi (valeur partagée, RG-5.01). Le net est
* recalculé serveur (RG-5.05).
*/
function buildFullPayload(): Record<string, unknown> {
return {
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
fullDate: full.date || null,
fullWeight: full.weight,
fullDsd: full.dsd,
fullMode: full.mode,
fullManualNumber: full.manualNumber || null,
}
}
return {
// contrepartie
counterpartyType,
counterpartyField,
clientIri,
supplierIri,
otherLabel,
setCounterpartyType,
// véhicule partagé
immatriculation,
plateFreeFormat,
// pesées
empty,
full,
applyReading,
// workflow
ticketId,
buildCreatePayload,
buildFullPayload,
}
}
@@ -0,0 +1,62 @@
import { ref } from 'vue'
/**
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
*
* Collections récupérées en entier via l'échappatoire `?pagination=false`
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
*
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
*/
/** Option au format attendu par MalioSelect ({ label, value }). */
export interface RefOption {
value: string
label: string
}
interface PartyMember {
'@id': string
companyName: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useWeighingTicketReferentials() {
const api = useApi()
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[]> {
const res = await api.get<{ member?: PartyMember[] }>(
url,
{ pagination: 'false' },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* 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).
*/
async function load(): Promise<void> {
await Promise.allSettled([
fetchAll('/clients').then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers').then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
return { clients, suppliers, load }
}