diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 641860b..955192d 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -710,6 +710,8 @@ "addTitle": "Ajouter un ticket de pesée", "emptyBlock": "Poids à vide", "fullBlock": "Poids à plein", + "number": "Numéro", + "site": "Site", "date": "Date", "weight": "Poids (Kg)", "dsd": "DSD", @@ -717,6 +719,7 @@ "plateFreeFormat": "Tout format", "save": "Enregistrer", "validate": "Valider", + "print": "Imprimer", "counterparty": { "type": "Fournisseur / Client / Autre", "supplier": "Fournisseur", @@ -742,6 +745,12 @@ "numberRequired": "Le numéro de pesée est obligatoire." } }, + "edit": { + "title": "Ticket de pesée {number}", + "titleFallback": "Modifier un ticket de pesée", + "loading": "Chargement du ticket…", + "notFound": "Ticket de pesée introuvable." + }, "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/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts index 945fc36..88d3c9d 100644 --- a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -102,4 +102,65 @@ describe('useWeighingTicketForm', () => { expect(full.fullDsd).toBe(2) expect(full.fullMode).toBe('AUTO') }) + + // ── Pré-remplissage (écran Modification, ERP-190) ───────────────────────── + it('hydrate pré-remplit l\'état depuis le détail (dates ISO ramenées à YYYY-MM-DD)', () => { + const form = useWeighingTicketForm() + form.hydrate({ + id: 9, + counterpartyType: 'CLIENT', + client: { '@id': '/api/clients/629' }, + immatriculation: 'AB-123-CD', + plateFreeFormat: false, + emptyDate: '2026-06-17T09:00:00+02:00', + emptyWeight: 7150, + emptyDsd: 1, + emptyMode: 'AUTO', + fullDate: '2026-06-17T09:12:00+02:00', + fullWeight: 14300, + fullDsd: 2, + fullMode: 'AUTO', + }) + + expect(form.ticketId.value).toBe(9) + expect(form.counterpartyType.value).toBe('CLIENT') + expect(form.counterpartyField.value).toBe('client') + expect(form.clientIri.value).toBe('/api/clients/629') + expect(form.immatriculation.value).toBe('AB-123-CD') + // Date datetime back -> date seule pour MalioDate. + expect(form.empty.date).toBe('2026-06-17') + expect(form.full.date).toBe('2026-06-17') + expect(form.empty.weight).toBe(7150) + expect(form.full.weight).toBe(14300) + }) + + it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => { + const form = useWeighingTicketForm() + form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' }) + expect(form.otherLabel.value).toBe('Reprise') + expect(form.supplierIri.value).toBeNull() + expect(form.plateFreeFormat.value).toBe(false) + // Pas de date back -> repli sur le jour (stub 2026-06-22). + expect(form.empty.date).toBe('2026-06-22') + expect(form.empty.weight).toBeNull() + }) + + it('buildUpdatePayload fusionne contrepartie + véhicule + les 2 pesées', () => { + const form = useWeighingTicketForm() + form.hydrate({ + id: 9, + counterpartyType: 'CLIENT', + client: { '@id': '/api/clients/629' }, + immatriculation: 'AB-123-CD', + emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO', + fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO', + }) + + const payload = form.buildUpdatePayload() + expect(payload.counterpartyType).toBe('CLIENT') + expect(payload.client).toBe('/api/clients/629') + expect(payload.emptyWeight).toBe(7150) + expect(payload.fullWeight).toBe(14300) + expect(payload.immatriculation).toBe('AB-123-CD') + }) }) diff --git a/frontend/modules/logistique/composables/useWeighingTicket.ts b/frontend/modules/logistique/composables/useWeighingTicket.ts new file mode 100644 index 0000000..c649b17 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicket.ts @@ -0,0 +1,53 @@ +import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge' +import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm' + +/** + * Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back + * § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels, + * lus avec un défaut côté hydratation du formulaire. + */ +export interface WeighingTicketDetail { + id: number + /** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */ + number: string + /** Site rattaché (embarqué) — immuable (RG-5.09). */ + site?: { id: number, name: string, code: string } | null + counterpartyType: CounterpartyType + client?: { '@id': string, companyName: string } | null + supplier?: { '@id': string, companyName: string } | null + otherLabel?: string | null + immatriculation?: string | null + plateFreeFormat?: boolean + // Pesée à vide + emptyDate?: string | null + emptyWeight?: number | null + emptyDsd?: number | null + emptyMode?: WeighbridgeMode | null + emptyManualNumber?: string | null + // Pesée à plein + fullDate?: string | null + fullWeight?: number | null + fullDsd?: number | null + fullMode?: WeighbridgeMode | null + fullManualNumber?: string | null + netWeight?: number | null +} + +/** + * Charge le détail d'un ticket de pesée pour l'écran de modification (M5, + * ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations + * embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`). + */ +export function useWeighingTicket() { + const api = useApi() + + async function fetchTicket(id: number | string): Promise { + return await api.get( + `/weighing_tickets/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + return { fetchTicket } +} diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts index 9a6cdc2..149129c 100644 --- a/frontend/modules/logistique/composables/useWeighingTicketForm.ts +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -39,6 +39,32 @@ export interface WeighingBlockState { manualNumber: string | null } +/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */ +export interface WeighingTicketHydration { + id: number + counterpartyType: CounterpartyType + client?: { '@id': string } | null + supplier?: { '@id': string } | null + otherLabel?: string | null + immatriculation?: string | null + plateFreeFormat?: boolean + emptyDate?: string | null + emptyWeight?: number | null + emptyDsd?: number | null + emptyMode?: WeighbridgeMode | null + emptyManualNumber?: string | null + fullDate?: string | null + fullWeight?: number | null + fullDsd?: number | null + fullMode?: WeighbridgeMode | null + fullManualNumber?: string | null +} + +/** Extrait la partie date `YYYY-MM-DD` d'une chaîne ISO (datetime back) — null si absente. */ +function isoDateOnly(value: string | null | undefined): string | null { + return value ? value.slice(0, 10) : null +} + /** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */ function emptyBlock(today: string): WeighingBlockState { return { @@ -137,6 +163,44 @@ export function useWeighingTicketForm() { } } + /** + * Pré-remplit le formulaire à partir du détail d'un ticket existant (écran + * Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) → + * non repris dans l'état éditable (affichés en lecture seule par l'écran). + * Les dates ISO du back (datetime) sont ramenées à `YYYY-MM-DD` pour MalioDate. + */ + function hydrate(detail: WeighingTicketHydration): void { + ticketId.value = detail.id + counterpartyType.value = detail.counterpartyType ?? null + clientIri.value = detail.client?.['@id'] ?? null + supplierIri.value = detail.supplier?.['@id'] ?? null + otherLabel.value = detail.otherLabel ?? null + immatriculation.value = detail.immatriculation ?? null + plateFreeFormat.value = detail.plateFreeFormat ?? false + + empty.date = isoDateOnly(detail.emptyDate) ?? today + empty.weight = detail.emptyWeight ?? null + empty.dsd = detail.emptyDsd ?? null + empty.mode = detail.emptyMode ?? null + empty.manualNumber = detail.emptyManualNumber ?? null + + full.date = isoDateOnly(detail.fullDate) ?? today + full.weight = detail.fullWeight ?? null + full.dsd = detail.fullDsd ?? null + full.mode = detail.fullMode ?? null + full.manualNumber = detail.fullManualNumber ?? null + } + + /** + * Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les + * champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le + * site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net + * est recalculé serveur (RG-5.05). + */ + function buildUpdatePayload(): Record { + return { ...buildCreatePayload(), ...buildFullPayload() } + } + /** * 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 @@ -172,7 +236,9 @@ export function useWeighingTicketForm() { applyReading, // workflow ticketId, + hydrate, buildCreatePayload, buildFullPayload, + buildUpdatePayload, } } diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts new file mode 100644 index 0000000..05b8f38 --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref, reactive, Suspense } from 'vue' + +// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le +// pré-remplissage via hydrate). ───────────────────────────────────────────── +const mockFetchTicket = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) +const mockPush = vi.hoisted(() => vi.fn()) +const mockOpen = vi.hoisted(() => vi.fn()) + +vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({ + useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }), +})) +vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({ + useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }), +})) +vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({ + useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }), +})) + +// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useHead', () => undefined) +vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch })) +vi.stubGlobal('useRoute', () => ({ params: { id: '9' } })) +vi.stubGlobal('useRouter', () => ({ push: mockPush })) +vi.stubGlobal('usePermissions', () => ({ can: () => true })) +vi.stubGlobal('navigateTo', vi.fn()) +vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), clearErrors: vi.fn(), handleApiError: vi.fn() })) +globalThis.open = mockOpen + +const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default + +// ── Stubs de composants ────────────────────────────────────────────────────── +const ButtonStub = defineComponent({ + props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, + emits: ['click'], + setup(props, { emit }) { + return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) + }, +}) + +const InputStub = defineComponent({ + props: { label: { type: String, default: '' }, modelValue: { default: null } }, + setup(props) { + return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) + }, +}) + +// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide). +const BlockStub = defineComponent({ + setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) }, +}) + +const ModalStub = defineComponent({ + props: { modelValue: { type: Boolean, default: false } }, + setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) }, +}) + +const stubs = { + MalioButtonIcon: ButtonStub, + MalioButton: ButtonStub, + MalioInputText: InputStub, + MalioInputNumber: InputStub, + MalioSelect: InputStub, + MalioDate: InputStub, + MalioCheckbox: InputStub, + MalioModal: ModalStub, + WeighingBlock: BlockStub, +} + +// Monte la page (setup async : top-level await) via Suspense. +async function mountPage() { + const wrapper = mount(defineComponent({ + components: { EditPage }, + setup: () => () => h(Suspense, null, { default: () => h(EditPage) }), + }), { global: { stubs } }) + await flushPromises() + return wrapper +} + +const DETAIL = { + id: 9, + number: '86-TP-0001', + site: { id: 1, name: 'Chatellerault', code: '86' }, + counterpartyType: 'CLIENT', + client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' }, + immatriculation: 'AB-123-CD', + plateFreeFormat: false, + emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO', + fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO', +} + +describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => { + beforeEach(() => { + mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL }) + mockPatch.mockReset().mockResolvedValue({}) + mockPush.mockReset() + mockOpen.mockReset() + }) + + it('pré-remplit le numéro et le site en lecture seule (RG-5.09)', async () => { + const wrapper = await mountPage() + expect(mockFetchTicket).toHaveBeenCalledWith('9') + expect(wrapper.find('[data-label="logistique.weighingTickets.form.number"]').attributes('value')).toBe('86-TP-0001') + expect(wrapper.find('[data-label="logistique.weighingTickets.form.site"]').attributes('value')).toBe('Chatellerault') + }) + + it('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « Valider »', async () => { + const wrapper = await mountPage() + expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true) + expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true) + // « Valider » est le bouton de l'écran d'AJOUT — absent en modification (RG-5.08). + expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false) + }) + + it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => { + const wrapper = await mountPage() + await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click') + expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank') + }) + + it('« Enregistrer » PATCH le ticket puis revient à la liste', async () => { + const wrapper = await mountPage() + await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click') + await flushPromises() + expect(mockPatch).toHaveBeenCalledWith( + '/weighing_tickets/9', + expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }), + expect.objectContaining({ toast: false }), + ) + expect(mockPush).toHaveBeenCalledWith('/weighing-tickets') + }) +}) diff --git a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue new file mode 100644 index 0000000..fdcd4ee --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue @@ -0,0 +1,389 @@ + + +