diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d81eb38..641860b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -705,6 +705,43 @@ "date": "Date", "weight": "Poids" }, + "form": { + "back": "Retour à la liste", + "addTitle": "Ajouter un ticket de pesée", + "emptyBlock": "Poids à vide", + "fullBlock": "Poids à plein", + "date": "Date", + "weight": "Poids (Kg)", + "dsd": "DSD", + "immatriculation": "Immatriculation", + "plateFreeFormat": "Tout format", + "save": "Enregistrer", + "validate": "Valider", + "counterparty": { + "type": "Fournisseur / Client / Autre", + "supplier": "Fournisseur", + "client": "Client", + "other": "Autre" + }, + "weighbridge": { + "auto": "Pesée bascule", + "manual": "Pesée manuelle", + "confirmTitle": "Pesée bascule", + "confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?", + "cancel": "Annuler", + "validate": "Valider", + "unavailable": "Pont bascule indisponible — passez en pesée manuelle." + }, + "manual": { + "title": "Pesée manuelle", + "weight": "Poids (Kg)", + "number": "Numéro de pesée", + "save": "Enregistrer", + "cancel": "Annuler", + "weightRequired": "Le poids est obligatoire.", + "numberRequired": "Le numéro de pesée est obligatoire." + } + }, "toast": { "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export des tickets de pesée a échoué. Réessayez." diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue new file mode 100644 index 0000000..5218be5 --- /dev/null +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts new file mode 100644 index 0000000..8ff8e72 --- /dev/null +++ b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts @@ -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() + }) +}) diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts new file mode 100644 index 0000000..945fc36 --- /dev/null +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -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') + }) +}) diff --git a/frontend/modules/logistique/composables/useWeighbridge.ts b/frontend/modules/logistique/composables/useWeighbridge.ts new file mode 100644 index 0000000..2f08b19 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighbridge.ts @@ -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 { + return await api.post( + '/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 { + return await api.post( + '/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 } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts new file mode 100644 index 0000000..9a6cdc2 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -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(null) + const clientIri = ref(null) + const supplierIri = ref(null) + const otherLabel = ref(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(null) + const plateFreeFormat = ref(false) + + // ── Les deux pesées ─────────────────────────────────────────────────────── + const empty = reactive(emptyBlock(today)) + const full = reactive(emptyBlock(today)) + + // Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein. + const ticketId = ref(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 { + 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 { + 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 { + 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, + } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts b/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts new file mode 100644 index 0000000..15c592e --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketReferentials.ts @@ -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([]) + const suppliers = ref([]) + + /** Récupère une collection complète (pagination désactivée) en Hydra. */ + async function fetchAll(url: string): Promise { + 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 { + 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 } +} diff --git a/frontend/modules/logistique/pages/weighing-tickets/new.vue b/frontend/modules/logistique/pages/weighing-tickets/new.vue new file mode 100644 index 0000000..9121252 --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/new.vue @@ -0,0 +1,375 @@ + + +