From 117dcdbdcc478c692eb49d26846f27f18fca7cb4 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 15:03:02 +0200 Subject: [PATCH 01/20] =?UTF-8?q?feat(front)=20:=20page=20liste=20des=20ti?= =?UTF-8?q?ckets=20de=20pes=C3=A9e=20+=20export=20(ERP-188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 20 ++ .../useWeighingTicketsRepository.ts | 63 ++++++ .../__tests__/weighingTicketsIndex.spec.ts | 168 ++++++++++++++++ .../pages/weighing-tickets/index.vue | 179 ++++++++++++++++++ 4 files changed, 430 insertions(+) create mode 100644 frontend/modules/logistique/composables/useWeighingTicketsRepository.ts create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts create mode 100644 frontend/modules/logistique/pages/weighing-tickets/index.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cc62b73..d81eb38 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -691,6 +691,26 @@ } } }, + "logistique": { + "weighingTickets": { + "title": "Tickets de pesée", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun ticket de pesée pour l'instant.", + "column": { + "number": "Numéro", + "client": "Client", + "supplier": "Fournisseur", + "other": "Autre", + "date": "Date", + "weight": "Poids" + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export des tickets de pesée a échoué. Réessayez." + } + } + }, "auth": { "login": "Connexion", "logout": "Deconnexion", diff --git a/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts new file mode 100644 index 0000000..b1a1536 --- /dev/null +++ b/frontend/modules/logistique/composables/useWeighingTicketsRepository.ts @@ -0,0 +1,63 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la + * LISTE des tickets de pesee. Seul `companyName` alimente les colonnes + * « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` / + * `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`, + * spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page. + */ +export interface WeighingTicketParty { + id: number + companyName: string | null +} + +/** + * Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement + * partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont + * types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve + * de l'ecran Modification (ERP-190) — hors perimetre de cet ecran. + * + * Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier` + * / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null). + * `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0). + * `netWeight` = plein − vide en kg (RG-5.05). + */ +export interface WeighingTicket { + id: number + /** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */ + number: string + /** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */ + client: WeighingTicketParty | null + /** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */ + supplier: WeighingTicketParty | null + /** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */ + otherLabel: string | null + /** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */ + displayDate: string | null + /** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */ + netWeight: number | null +} + +/** + * Filtres de la liste des tickets de pesee, branches sur les query params de + * `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs + * cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le + * front n'a pas a envoyer le site. + */ +export interface WeighingTicketFilters { + search?: string +} + +/** + * Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de + * `usePaginatedList` sur la ressource `/weighing_tickets` + * (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination + * serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6). + * + * Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de + * singleton) : l'etat tableau est propre a l'ecran et meurt avec lui. + */ +export function useWeighingTicketsRepository() { + return usePaginatedList({ url: '/weighing_tickets' }) +} diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts new file mode 100644 index 0000000..ee5c2ea --- /dev/null +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref } from 'vue' + +// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── +// La page ne les importe pas (auto-import) : on les expose en globals pour le +// runtime de test (happy-dom). Meme philosophie que les specs M1→M4. +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 mockToastError = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useHead', () => undefined) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) +vi.stubGlobal('useRouter', () => ({ push: mockPush })) +vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) +vi.stubGlobal('usePermissions', () => ({ can: mockCan })) + +// Le repository est lui aussi un auto-import : on controle les items renvoyes. +// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values). +vi.stubGlobal('useWeighingTicketsRepository', () => ({ + items: ref([ + { + id: 9, + number: '86-TP-0001', + client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' }, + supplier: null, + otherLabel: null, + displayDate: '2026-06-17T09:12:00+02:00', + netWeight: 7150, + }, + ]), + totalItems: ref(1), + currentPage: ref(1), + itemsPerPage: ref(10), + itemsPerPageOptions: ref([10, 25, 50]), + fetch: mockFetch, + goToPage: vi.fn(), + setItemsPerPage: vi.fn(), + setFilters: vi.fn(), +})) + +// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques +// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse). +globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') +globalThis.URL.revokeObjectURL = vi.fn() + +// Import APRES les stubs (la page resout les auto-imports au top-level du module). +const WeighingTicketsIndex = (await import('../weighing-tickets/index.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) + }, +}) + +// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses +// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes. +const capturedRows = ref>>([]) +const DataTableStub = defineComponent({ + props: { items: { type: Array, default: () => [] } }, + emits: ['row-click', 'update:page', 'update:per-page'], + setup(props, { emit }) { + return () => { + capturedRows.value = props.items as Array> + return h('div', { 'data-testid': 'datatable' }, + (props.items as Array>).map(it => + h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [ + h('td', { 'data-cell': 'displayDate' }, it.displayDate as string), + h('td', { 'data-cell': 'netWeight' }, it.netWeight as string), + h('td', { 'data-cell': 'client' }, it.client as string), + h('td', { 'data-cell': 'supplier' }, it.supplier as string), + ]), + ), + ) + } + }, +}) + +const PageHeaderStub = defineComponent({ + setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, +}) + +function mountPage() { + return mount(WeighingTicketsIndex, { + global: { + stubs: { + PageHeader: PageHeaderStub, + MalioButton: ButtonStub, + MalioDataTable: DataTableStub, + }, + }, + }) +} + +describe('Liste des tickets de pesée (page /weighing-tickets)', () => { + beforeEach(() => { + mockPush.mockReset() + mockApiGet.mockReset().mockResolvedValue(new Blob()) + mockCan.mockReset().mockReturnValue(true) + mockFetch.mockReset() + mockToastError.mockReset() + capturedRows.value = [] + }) + + it('charge la liste au montage', async () => { + mountPage() + await flushPromises() + expect(mockFetch).toHaveBeenCalled() + }) + + it('formate la date au format JJ-MM-AAAA', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026') + }) + + it('formate le poids net en kg avec separateur de milliers', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg') + }) + + it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => { + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE') + expect(wrapper.find('[data-cell="supplier"]').text()).toBe('') + }) + + it('affiche « + Ajouter » uniquement avec la permission manage', async () => { + mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true) + }) + + it('masque « + Ajouter » sans la permission manage (view seul)', async () => { + mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view') + const wrapper = mountPage() + await flushPromises() + expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false) + }) + + it('navigue vers la modification au clic sur une ligne', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('tr[data-row-id="9"]').trigger('click') + expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit') + }) + + it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => { + const wrapper = mountPage() + await flushPromises() + await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click') + await flushPromises() + expect(mockApiGet).toHaveBeenCalledWith( + '/weighing_tickets/export.xlsx', + expect.any(Object), + expect.objectContaining({ responseType: 'blob', toast: false }), + ) + }) +}) diff --git a/frontend/modules/logistique/pages/weighing-tickets/index.vue b/frontend/modules/logistique/pages/weighing-tickets/index.vue new file mode 100644 index 0000000..1bea549 --- /dev/null +++ b/frontend/modules/logistique/pages/weighing-tickets/index.vue @@ -0,0 +1,179 @@ + + + -- 2.39.5 From ef7bf699808290deb75a337dc8fffed1e56a8b31 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 15:03:02 +0200 Subject: [PATCH 02/20] =?UTF-8?q?style(front)=20:=20section=20Logistique?= =?UTF-8?q?=20en=20t=C3=AAte=20de=20sidebar=20+=20ic=C3=B4ne=20camion=20(E?= =?UTF-8?q?RP-188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/sidebar.php | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/config/sidebar.php b/config/sidebar.php index 91387d6..5f5d3a0 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -38,7 +38,27 @@ declare(strict_types=1); */ return [ - // Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71). + // Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur + // site", distinct du repertoire Transport (M4, desormais rattache a la section + // Administration cote develop). Porte le ticket de pesee au pont bascule. + // Placee en tete de sidebar (avant Commerciale). L'item est gate par + // `logistique.weighing_tickets.view` ; la section disparait automatiquement + // (SidebarProvider) si le module `logistique` est desactive ou si l'user n'a + // pas la permission (Compta / Commerciale). + [ + 'label' => 'sidebar.logistique.section', + 'icon' => 'mdi:truck-outline', + 'items' => [ + [ + 'label' => 'sidebar.logistique.weighing_tickets', + 'to' => '/weighing-tickets', + 'icon' => 'mdi:truck-outline', + 'module' => 'logistique', + 'permission' => 'logistique.weighing_tickets.view', + ], + ], + ], + // Section "Commerciale" : pole metier principal (ERP-71). // L'ordre interne des onglets et les permissions restent inchanges (simple deplacement // du bloc, aucun gate touche). [ @@ -78,25 +98,6 @@ return [ ], ], ], - // Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur - // site", distinct du repertoire Transport (M4, desormais rattache a la section - // Administration cote develop). Porte le ticket de pesee au pont bascule. - // L'item est gate par `logistique.weighing_tickets.view` ; la section disparait - // automatiquement (SidebarProvider) si le module `logistique` est desactive ou - // si l'user n'a pas la permission (Compta / Commerciale). - [ - 'label' => 'sidebar.logistique.section', - 'icon' => 'mdi:scale', - 'items' => [ - [ - 'label' => 'sidebar.logistique.weighing_tickets', - 'to' => '/weighing-tickets', - 'icon' => 'mdi:scale', - 'module' => 'logistique', - 'permission' => 'logistique.weighing_tickets.view', - ], - ], - ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // -- 2.39.5 From 9f3fe4da4e049af6383a88cfad39def45e5de415 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 15:11:54 +0200 Subject: [PATCH 03/20] =?UTF-8?q?feat(front)=20:=20=C3=A9cran=20ajouter=20?= =?UTF-8?q?un=20ticket=20de=20pes=C3=A9e=20(blocs=20vide/plein,=20pes?= =?UTF-8?q?=C3=A9e,=20masque=20immat)=20(ERP-189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 37 ++ .../logistique/components/WeighingBlock.vue | 132 ++++++ .../__tests__/useWeighbridge.spec.ts | 60 +++ .../__tests__/useWeighingTicketForm.spec.ts | 105 +++++ .../logistique/composables/useWeighbridge.ts | 74 ++++ .../composables/useWeighingTicketForm.ts | 178 +++++++++ .../useWeighingTicketReferentials.ts | 62 +++ .../logistique/pages/weighing-tickets/new.vue | 375 ++++++++++++++++++ 8 files changed, 1023 insertions(+) create mode 100644 frontend/modules/logistique/components/WeighingBlock.vue create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts create mode 100644 frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts create mode 100644 frontend/modules/logistique/composables/useWeighbridge.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicketForm.ts create mode 100644 frontend/modules/logistique/composables/useWeighingTicketReferentials.ts create mode 100644 frontend/modules/logistique/pages/weighing-tickets/new.vue 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 @@ + + + -- 2.39.5 From b438838465eeaa7bd4a4b00176a9362916dbabff Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 15:29:15 +0200 Subject: [PATCH 04/20] =?UTF-8?q?feat(front)=20:=20=C3=A9cran=20modificati?= =?UTF-8?q?on=20d'un=20ticket=20de=20pes=C3=A9e=20+=20bouton=20imprimer=20?= =?UTF-8?q?(ERP-190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 9 + .../__tests__/useWeighingTicketForm.spec.ts | 61 +++ .../composables/useWeighingTicket.ts | 53 +++ .../composables/useWeighingTicketForm.ts | 66 +++ .../__tests__/weighingTicketEdit.spec.ts | 135 ++++++ .../pages/weighing-tickets/[id]/edit.vue | 389 ++++++++++++++++++ 6 files changed, 713 insertions(+) create mode 100644 frontend/modules/logistique/composables/useWeighingTicket.ts create mode 100644 frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts create mode 100644 frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue 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 @@ + + + -- 2.39.5 From 4dcc24743628d13d262d0c1f62c9b21b5b3de215 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 16:13:30 +0200 Subject: [PATCH 05/20] =?UTF-8?q?feat(front)=20:=20branchement=20site=20co?= =?UTF-8?q?urant=20+=20formats=20d'affichage=20des=20tickets=20de=20pes?= =?UTF-8?q?=C3=A9e=20(ERP-191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/weighingTicketsIndex.spec.ts | 38 ++++++++++++-- .../pages/weighing-tickets/index.vue | 49 +++++++---------- .../__tests__/weighingTicketFormat.spec.ts | 52 +++++++++++++++++++ .../logistique/utils/weighingTicketFormat.ts | 46 ++++++++++++++++ 4 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts create mode 100644 frontend/modules/logistique/utils/weighingTicketFormat.ts diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts index ee5c2ea..708a940 100644 --- a/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketsIndex.spec.ts @@ -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() diff --git a/frontend/modules/logistique/pages/weighing-tickets/index.vue b/frontend/modules/logistique/pages/weighing-tickets/index.vue index 1bea549..560ccf5 100644 --- a/frontend/modules/logistique/pages/weighing-tickets/index.vue +++ b/frontend/modules/logistique/pages/weighing-tickets/index.vue @@ -45,13 +45,18 @@ diff --git a/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts b/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts new file mode 100644 index 0000000..028994a --- /dev/null +++ b/frontend/modules/logistique/utils/__tests__/weighingTicketFormat.spec.ts @@ -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('') + }) + }) +}) diff --git a/frontend/modules/logistique/utils/weighingTicketFormat.ts b/frontend/modules/logistique/utils/weighingTicketFormat.ts new file mode 100644 index 0000000..25d022a --- /dev/null +++ b/frontend/modules/logistique/utils/weighingTicketFormat.ts @@ -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() : '' +} -- 2.39.5 From 68e72057938af7e6e99b55ab0051ffcb2b91c1bf Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 23 Jun 2026 14:03:32 +0200 Subject: [PATCH 06/20] =?UTF-8?q?fix(back)=20:=20422=20de=20validation=20m?= =?UTF-8?q?appables=20+=20poids=20obligatoire=20sur=20le=20ticket=20de=20p?= =?UTF-8?q?es=C3=A9e=20(ERP-189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collectDenormalizationErrors sur Post/Patch : les erreurs de dénormalisation (date/type/IRI) reviennent en 422 avec propertyPath (et non 400 opaque), donc mappables inline côté front (miroir M1 Client). - NotBlank sur emptyWeight : le poids à vide est obligatoire à la création, sa violation est renvoyée avec counterpartyType / immatriculation d'un seul coup. --- .../Logistique/Domain/Entity/WeighingTicket.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Module/Logistique/Domain/Entity/WeighingTicket.php b/src/Module/Logistique/Domain/Entity/WeighingTicket.php index 5f412c7..7e6bf1d 100644 --- a/src/Module/Logistique/Domain/Entity/WeighingTicket.php +++ b/src/Module/Logistique/Domain/Entity/WeighingTicket.php @@ -95,6 +95,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'default:read', ]], denormalizationContext: ['groups' => ['weighing_ticket:write']], + // Erreurs de denormalisation (date non parsable, type/IRI invalide) + // remontees en 422 avec propertyPath (et non 400 opaque) -> mapping + // inline par champ cote front via useFormErrors (miroir M1 Client). + collectDenormalizationErrors: true, processor: WeighingTicketProcessor::class, ), new Patch( @@ -108,6 +112,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'default:read', ]], denormalizationContext: ['groups' => ['weighing_ticket:write']], + collectDenormalizationErrors: true, provider: WeighingTicketProvider::class, processor: WeighingTicketProcessor::class, ), @@ -190,8 +195,15 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?DateTimeImmutable $emptyDate = null; - /** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */ + /** + * Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). + * Obligatoire : un ticket est cree APRES la pesee a vide (POST). NotBlank ici + * (et non sur empty_dsd, alloue serveur) rend la 422 « poids obligatoire » + * coherente avec les autres champs requis (counterpartyType / immatriculation), + * toutes renvoyees d'un coup -> mapping inline front (ERP-101). + */ #[ORM\Column(name: 'empty_weight', nullable: true)] + #[Assert\NotBlank(message: 'Le poids est obligatoire : effectuez une pesée.')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $emptyWeight = null; -- 2.39.5 From 5349c3c4d5baa492c5cc241b3ba38b8c0de15ee0 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 23 Jun 2026 14:03:32 +0200 Subject: [PATCH 07/20] =?UTF-8?q?fix(front)=20:=20ajustements=20du=20formu?= =?UTF-8?q?laire=20ticket=20de=20pes=C3=A9e=20(ERP-189/190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Poids/DSD en champs texte verrouillés sur les chiffres et désactivés. - Boutons de pesée : icône mdi:weight à gauche + gap-8. - Bloc « Poids à vide » réagencé en 3 lignes (contrepartie / Date-Poids-DSD-Immat / Tout format). - Omission des clés null dans les payloads (compact) : requis vides → message NotBlank métier au lieu d'une erreur de type. - Pesée obligatoire (RG-5.07) signalée inline sous Poids/DSD ; toutes les violations affichées d'un seul aller-retour. - Erreur d'immatriculation affichée uniquement sur le bloc « Poids à vide » (plus de doublon sur le bloc plein). --- frontend/i18n/locales/fr.json | 2 + .../logistique/components/WeighingBlock.vue | 137 +++++++++++------- .../__tests__/useWeighingTicketForm.spec.ts | 25 ++++ .../composables/useWeighingTicketForm.ts | 37 ++++- .../__tests__/weighingTicketEdit.spec.ts | 2 +- .../pages/weighing-tickets/[id]/edit.vue | 29 +++- .../logistique/pages/weighing-tickets/new.vue | 34 ++++- 7 files changed, 200 insertions(+), 66 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 955192d..94ab9a9 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -720,6 +720,8 @@ "save": "Enregistrer", "validate": "Valider", "print": "Imprimer", + "weightRequired": "Le poids est obligatoire : effectuez une pesée.", + "dsdRequired": "Le DSD est obligatoire : effectuez une pesée.", "counterparty": { "type": "Fournisseur / Client / Autre", "supplier": "Fournisseur", diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue index 5218be5..95927f6 100644 --- a/frontend/modules/logistique/components/WeighingBlock.vue +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -3,15 +3,19 @@

{{ title }}

-
+
-
- - +
+ +
+ +
- - + +
+ + - - + + - - + + - - + + +
- - + +
+ +
@@ -99,6 +115,14 @@ const PLATE_MASK = { tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } }, } +// Masque « chiffres uniquement » (maska, longueur libre) pour Poids et DSD : +// ces champs texte sont verrouillés sur des entiers, et de toute façon désactivés +// (remplis par la pesée). +const NUMERIC_MASK = { + mask: 'D', + tokens: { D: { pattern: /[0-9]/, multiple: true } }, +} + const props = defineProps<{ /** Identifiant technique du bloc (pour les `id` de champs uniques). */ blockId: string @@ -125,6 +149,11 @@ const { t } = useI18n() const errors = computed(() => props.errors ?? {}) +// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide +// tant que la pesée n'a pas rempli la valeur). +const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight))) +const dsdDisplay = computed(() => (props.block.dsd === null ? '' : String(props.block.dsd))) + /** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */ function emitBlock(field: keyof WeighingBlockState, value: unknown): void { emit('update:block', field, value) diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts index 88d3c9d..11ba313 100644 --- a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -15,6 +15,31 @@ describe('useWeighingTicketForm', () => { expect(form.counterpartyType.value).toBeNull() }) + // ── Omission des requis vides (compact) ────────────────────────────────── + it('buildCreatePayload omet les clés null (requis vides absents, pas envoyés à null)', () => { + const form = useWeighingTicketForm() + // Formulaire vierge : counterpartyType / immatriculation non remplis. + const payload = form.buildCreatePayload() + // Absents (et non null) → le back applique NotBlank (message métier) plutôt + // qu'une erreur de type opaque (« doit être de type string »). + expect(payload).not.toHaveProperty('counterpartyType') + expect(payload).not.toHaveProperty('immatriculation') + expect(payload).not.toHaveProperty('emptyWeight') + // Les non-null restent : date du jour + booléen Tout format. + expect(payload.emptyDate).toBe('2026-06-22') + expect(payload.plateFreeFormat).toBe(false) + }) + + // ── Pesée obligatoire front-only (RG-5.07) ─────────────────────────────── + it('missingWeighingFields liste Poids/DSD manquants, puis vide après pesée', () => { + const form = useWeighingTicketForm() + expect(form.missingWeighingFields('empty')).toEqual(['emptyWeight', 'emptyDsd']) + expect(form.missingWeighingFields('full')).toEqual(['fullWeight', 'fullDsd']) + + form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' }) + expect(form.missingWeighingFields('empty')).toEqual([]) + }) + // ── Contrepartie conditionnelle (RG-5.03) ──────────────────────────────── it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => { const form = useWeighingTicketForm() diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts index 149129c..ffa9a38 100644 --- a/frontend/modules/logistique/composables/useWeighingTicketForm.ts +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -65,6 +65,19 @@ function isoDateOnly(value: string | null | undefined): string | null { return value ? value.slice(0, 10) : null } +/** + * Retire les clés à valeur `null` d'un payload (pattern « omission des requis + * vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur + * un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE + * opaque (« Cette valeur doit être de type string. ») au lieu du message métier + * `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank` + * et son message FR. On omet donc les null ; les champs réellement requis non + * remplis déclenchent leur vrai message, les optionnels restent simplement absents. + */ +function compact(payload: Record): Record { + return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null)) +} + /** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */ function emptyBlock(today: string): WeighingBlockState { return { @@ -122,6 +135,21 @@ export function useWeighingTicketForm() { } }) + /** + * Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces + * colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été + * effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie + * les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à + * être posés en erreur inline via `useFormErrors.setError`. + */ + function missingWeighingFields(which: 'empty' | 'full'): string[] { + const block = which === 'empty' ? empty : full + const missing: string[] = [] + if (block.weight === null) missing.push(`${which}Weight`) + if (block.dsd === null) missing.push(`${which}Dsd`) + return missing + } + /** Applique une lecture de pesée (bascule/manuelle) à un bloc. */ function applyReading( block: WeighingBlockState, @@ -150,7 +178,7 @@ export function useWeighingTicketForm() { * pour que `useFormErrors` mappe les 422 inline. */ function buildCreatePayload(): Record { - return { + return compact({ counterpartyType: counterpartyType.value, ...counterpartyPayload(), immatriculation: immatriculation.value || null, @@ -160,7 +188,7 @@ export function useWeighingTicketForm() { emptyDsd: empty.dsd, emptyMode: empty.mode, emptyManualNumber: empty.manualNumber || null, - } + }) } /** @@ -208,7 +236,7 @@ export function useWeighingTicketForm() { * recalculé serveur (RG-5.05). */ function buildFullPayload(): Record { - return { + return compact({ immatriculation: immatriculation.value || null, plateFreeFormat: plateFreeFormat.value, fullDate: full.date || null, @@ -216,7 +244,7 @@ export function useWeighingTicketForm() { fullDsd: full.dsd, fullMode: full.mode, fullManualNumber: full.manualNumber || null, - } + }) } return { @@ -234,6 +262,7 @@ export function useWeighingTicketForm() { empty, full, applyReading, + missingWeighingFields, // workflow ticketId, hydrate, diff --git a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts index 05b8f38..53f39c7 100644 --- a/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts +++ b/frontend/modules/logistique/pages/__tests__/weighingTicketEdit.spec.ts @@ -27,7 +27,7 @@ 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() })) +vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() })) globalThis.open = mockOpen const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default diff --git a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue index fdcd4ee..b8684ad 100644 --- a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue +++ b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue @@ -213,7 +213,24 @@ const form = useWeighingTicketForm() const weighbridge = useWeighbridge() const referentials = useWeighingTicketReferentials() const { fetchTicket } = useWeighingTicket() -const { errors, clearErrors, handleApiError } = useFormErrors() +const { errors, setError, clearErrors, handleApiError } = useFormErrors() + +/** + * Marque Poids/DSD manquants d'un bloc (RG-5.07). `emptyWeight` est validé côté + * back (NotBlank → renvoyé avec les autres violations) ; `fullWeight` n'a pas + * d'équivalent back (workflow 2 temps) et reste donc front-only. Le DSD est + * alloué serveur → simple repère front en miroir du poids. Retourne false si une + * pesée manque. + */ +function validateWeighing(which: 'empty' | 'full'): boolean { + const missing = form.missingWeighingFields(which) + for (const path of missing) { + setError(path, path.endsWith('Weight') + ? t('logistique.weighingTickets.form.weightRequired') + : t('logistique.weighingTickets.form.dsdRequired')) + } + return missing.length === 0 +} const loading = ref(true) const error = ref(false) @@ -255,11 +272,13 @@ const emptyBlockErrors = computed>(() => ({ dsd: errors.emptyDsd, immatriculation: errors.immatriculation, })) +// Immatriculation volontairement ABSENTE ici : partagée entre les 2 blocs +// (RG-5.01) mais affichée/validée sur le bloc « Poids à vide » uniquement — pas +// de doublon d'erreur sur le bloc « Poids à plein ». const fullBlockErrors = computed>(() => ({ date: errors.fullDate, weight: errors.fullWeight, dsd: errors.fullDsd, - immatriculation: errors.immatriculation, })) /** Mute un champ d'un bloc de pesée (état centralisé dans le form). */ @@ -348,8 +367,12 @@ async function confirmManual(): Promise { /** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */ async function submitSave(): Promise { if (saving.value) return - saving.value = true clearErrors() + // Vide : marqué seulement (le back garde emptyWeight et renvoie tout d'un coup). + // Plein : bloquant côté front (pas de règle back, workflow 2 temps). + validateWeighing('empty') + if (!validateWeighing('full')) return + saving.value = true try { await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false }) router.push('/weighing-tickets') diff --git a/frontend/modules/logistique/pages/weighing-tickets/new.vue b/frontend/modules/logistique/pages/weighing-tickets/new.vue index 9121252..6c7999a 100644 --- a/frontend/modules/logistique/pages/weighing-tickets/new.vue +++ b/frontend/modules/logistique/pages/weighing-tickets/new.vue @@ -199,7 +199,23 @@ if (!can('logistique.weighing_tickets.manage')) { const form = useWeighingTicketForm() const weighbridge = useWeighbridge() const referentials = useWeighingTicketReferentials() -const { errors, clearErrors, handleApiError } = useFormErrors() +const { errors, setError, clearErrors, handleApiError } = useFormErrors() + +/** + * Validation front-only de la pesée d'un bloc (Poids + DSD obligatoires, RG-5.07). + * Le back rend ces colonnes nullable (workflow 2 temps), l'obligation est donc + * portée côté front (ERP-101). Pose l'erreur inline sous chaque champ manquant et + * retourne false si une pesée manque. + */ +function validateWeighing(which: 'empty' | 'full'): boolean { + const missing = form.missingWeighingFields(which) + for (const path of missing) { + setError(path, path.endsWith('Weight') + ? t('logistique.weighingTickets.form.weightRequired') + : t('logistique.weighingTickets.form.dsdRequired')) + } + return missing.length === 0 +} // Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués). const emptyLocked = computed(() => form.ticketId.value !== null) @@ -232,11 +248,14 @@ const emptyBlockErrors = computed>(() => ({ dsd: errors.emptyDsd, immatriculation: errors.immatriculation, })) +// Immatriculation volontairement ABSENTE ici : elle est partagée entre les 2 blocs +// (RG-5.01) mais saisie/validée sur le bloc « Poids à vide ». On n'affiche donc +// son erreur que sur le 1er bloc, pas en double sur le bloc « Poids à plein » +// (le formulaire se valide en 2 temps). const fullBlockErrors = computed>(() => ({ date: errors.fullDate, weight: errors.fullWeight, dsd: errors.fullDsd, - immatriculation: errors.immatriculation, })) /** Mute un champ d'un bloc de pesée (état centralisé dans le form). */ @@ -331,8 +350,13 @@ interface TicketResponse { id: number } /** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */ async function submitCreate(): Promise { if (creating.value) return - creating.value = true clearErrors() + // Marque Poids/DSD manquants pour un retour immédiat, mais on POSTe quand même : + // le back renvoie TOUTES les violations d'un coup (counterparty / immat / poids, + // NotBlank sur emptyWeight), comme les autres modules. Le DSD est alloué serveur + // (pas de règle back) → simple repère front en miroir du poids. + validateWeighing('empty') + creating.value = true try { const created = await api.post('/weighing_tickets', form.buildCreatePayload(), { headers: { Accept: 'application/ld+json' }, @@ -351,8 +375,10 @@ async function submitCreate(): Promise { /** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */ async function submitValidate(): Promise { if (validating.value || form.ticketId.value === null) return - validating.value = true clearErrors() + // Pesée à plein obligatoire (front-only) avant finalisation/impression. + if (!validateWeighing('full')) return + validating.value = true try { await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false }) // Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne -- 2.39.5 From f2c06aed43d2da48d9f7065f58a9ae31352420b1 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 23 Jun 2026 14:09:36 +0200 Subject: [PATCH 08/20] =?UTF-8?q?fix(front)=20:=20masque=20=C3=A9largi=20p?= =?UTF-8?q?our=20l'immatriculation=20=C2=AB=20Tout=20format=20=C2=BB=20(ER?= =?UTF-8?q?P-189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Tout format » n'est plus un champ libre total : masque maska charset (lettres/chiffres/espace/tiret, MAJ, longueur libre) pour les plaques anciennes ou étrangères, filtrant accents/ponctuation/symboles. Format autoritaire côté serveur. --- .../logistique/components/WeighingBlock.vue | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue index 95927f6..890cb95 100644 --- a/frontend/modules/logistique/components/WeighingBlock.vue +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -67,12 +67,14 @@ :error="errors.dsd" /> - c.toUpperCase() } }, } +// Masque « Tout format » (RG-5.01) : plaques anciennes / étrangères / engins. On +// autorise lettres, chiffres, espace et tiret, en MAJUSCULES, longueur libre — +// mais on filtre tout le reste (accents, ponctuation, symboles : « &é"'(_ç… »). +// Pattern maska charset du projet (cf. shared/utils/textSanitize) : `preProcess` +// retire d'abord les caractères hors charset (le token `multiple` glouton +// s'arrêterait sinon au 1er invalide), puis le token laisse passer le reste. +const FREE_PLATE_MASK = { + mask: 'P', + tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } }, + preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''), +} + // Masque « chiffres uniquement » (maska, longueur libre) pour Poids et DSD : // ces champs texte sont verrouillés sur des entiers, et de toute façon désactivés // (remplis par la pesée). -- 2.39.5 From 335d2ed207980b2a1673ebf05a0886a4d4cc09f1 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 23 Jun 2026 15:58:31 +0200 Subject: [PATCH 09/20] =?UTF-8?q?fix(front)=20:=20poids=20en=20champ=20tex?= =?UTF-8?q?te=20chiffr=C3=A9=20dans=20la=20pes=C3=A9e=20manuelle=20+=20ret?= =?UTF-8?q?rait=20num=C3=A9ro/site=20sur=20la=20modification=20(ERP-189/19?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modale « Pesée manuelle » : champ Poids passé en MalioInputText verrouillé sur les chiffres (NUMERIC_MASK), comme le formulaire. - Masques de pesée factorisés dans utils/weighingMasks (NUMERIC / PLATE / FREE_PLATE). - Écran Modification : suppression des champs lecture seule « Numéro » et « Site » en tête (le numéro reste rappelé dans le titre de l'écran). --- frontend/i18n/locales/fr.json | 2 - .../logistique/components/WeighingBlock.vue | 29 +------------- .../__tests__/weighingTicketEdit.spec.ts | 6 +-- .../pages/weighing-tickets/[id]/edit.vue | 31 ++++----------- .../logistique/pages/weighing-tickets/new.vue | 8 ++-- .../modules/logistique/utils/weighingMasks.ts | 39 +++++++++++++++++++ 6 files changed, 56 insertions(+), 59 deletions(-) create mode 100644 frontend/modules/logistique/utils/weighingMasks.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 94ab9a9..3f418be 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -710,8 +710,6 @@ "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", diff --git a/frontend/modules/logistique/components/WeighingBlock.vue b/frontend/modules/logistique/components/WeighingBlock.vue index 890cb95..1c93628 100644 --- a/frontend/modules/logistique/components/WeighingBlock.vue +++ b/frontend/modules/logistique/components/WeighingBlock.vue @@ -101,6 +101,7 @@ diff --git a/migrations/Version20260624100000.php b/migrations/Version20260624100000.php new file mode 100644 index 0000000..863454d --- /dev/null +++ b/migrations/Version20260624100000.php @@ -0,0 +1,91 @@ + valide. + * + * Le metier peut desormais enregistrer une pesee (bascule ou manuelle) SANS avoir + * rempli la contrepartie ni l'immatriculation : le ticket est cree « brouillon » + * des la 1ere pesee, puis « valide » (numero attribue, status VALIDATED) quand les + * 3 champs requis (type + champ contrepartie + immatriculation) ET les 2 pesees + * sont renseignes. + * + * Schema impacte : + * - `counterparty_type`, `immatriculation`, `number` passent NULLABLE (un brouillon + * n'a encore ni contrepartie, ni immat, ni numero — le numero n'est attribue + * qu'a la validation pour eviter les trous de sequence). Les CHECK de branche + * chk_wt_*_branch tolerent deja un counterparty_type NULL (NULL <> 'X' = NULL, + * donc CHECK non viole). + * - nouvelle colonne `status` (DRAFT|VALIDATED). Les tickets EXISTANTS (crees sous + * l'ancien flux, donc complets) sont retro-marques VALIDATED ; le defaut des + * nouvelles lignes est DRAFT. + * + * Namespace racine `DoctrineMigrations` (et non modulaire) : la migration ALTER une + * table creee par la migration racine Version20260617150000. Doctrine Migrations + * 3.x trie par FQCN alphabetique entre namespaces -> une migration modulaire + * `App\Module\...` passerait AVANT la racine sur base vide (make db-reset) et + * tenterait l'ALTER avant le CREATE. Le namespace racine garantit le tri par + * timestamp (regle ABSOLUE n°11, cf. Version20260617170000 pour site.code). + */ +final class Version20260624100000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-193 : weighing_ticket brouillon/valide (counterparty_type/immatriculation/number nullable + colonne status).'; + } + + public function up(Schema $schema): void + { + // Brouillon : ni contrepartie, ni immat, ni numero tant que non valide. + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type DROP NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation DROP NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number DROP NOT NULL'); + + // Statut du cycle de vie. Colonne ajoutee nullable, retro-remplie a VALIDATED + // pour les tickets existants (complets), puis figee NOT NULL DEFAULT DRAFT. + $this->addSql('ALTER TABLE weighing_ticket ADD COLUMN status VARCHAR(12)'); + $this->addSql("UPDATE weighing_ticket SET status = 'VALIDATED'"); + $this->addSql("ALTER TABLE weighing_ticket ALTER COLUMN status SET DEFAULT 'DRAFT'"); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN status SET NOT NULL'); + $this->addSql("ALTER TABLE weighing_ticket ADD CONSTRAINT chk_wt_status CHECK (status IN ('DRAFT','VALIDATED'))"); + + // Commentaires (regle ABSOLUE n°12). + $this->comment('weighing_ticket', 'status', "Cycle de vie : DRAFT (En attente, pesee enregistree sans contrepartie/immat) ou VALIDATED (Terminee, valide avec numero). Defaut DRAFT."); + $this->comment('weighing_ticket', 'number', "Numero {siteCode}-TP-{NNNN}, unique par site, immuable. NULL tant que le ticket est brouillon : attribue a la validation (RG-5.02, ERP-193)."); + $this->comment('weighing_ticket', 'counterparty_type', "Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). NULL tant que brouillon ; requise a la validation. Pilote l'obligation client_id / supplier_id / other_label."); + $this->comment('weighing_ticket', 'immatriculation', "Plaque du vehicule, partagee entre pesee vide et plein (RG-5.01). NULL tant que brouillon ; requise a la validation. Masque XX-000-XX sauf plate_free_format."); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE weighing_ticket DROP CONSTRAINT IF EXISTS chk_wt_status'); + $this->addSql('ALTER TABLE weighing_ticket DROP COLUMN IF EXISTS status'); + + // Restauration NOT NULL : echoue s'il subsiste des brouillons (number / + // counterparty_type / immatriculation NULL) — irreversible en presence de + // donnees brouillon, ce qui est attendu (le down sert au dev sur base saine). + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number SET NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation SET NOT NULL'); + $this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type SET NOT NULL'); + } + + /** + * Pose un COMMENT ON COLUMN en dollar-quoting Postgres ($_$...$_$) pour eviter + * tout echappement d'apostrophes dans les descriptions. + */ + private function comment(string $table, string $column, string $description): void + { + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + '"'.str_replace('"', '""', $table).'"', + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Module/Logistique/Domain/Entity/WeighingTicket.php b/src/Module/Logistique/Domain/Entity/WeighingTicket.php index 7c27491..43d1625 100644 --- a/src/Module/Logistique/Domain/Entity/WeighingTicket.php +++ b/src/Module/Logistique/Domain/Entity/WeighingTicket.php @@ -129,6 +129,29 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; provider: WeighingTicketProvider::class, processor: WeighingTicketProcessor::class, ), + // Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule + // operation qui exige le groupe `finalize` (contrepartie + immatriculation + + // les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status + // a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation + // Default relachee, on enregistre une pesee sans contrepartie/immat). + new Patch( + uriTemplate: '/weighing_tickets/{id}/validate', + name: 'weighing_ticket_validate', + security: "is_granted('logistique.weighing_tickets.manage')", + normalizationContext: ['groups' => [ + 'weighing_ticket:read', + 'weighing_ticket:item:read', + 'client:read', + 'supplier:read', + 'site:read', + 'default:read', + ]], + denormalizationContext: ['groups' => ['weighing_ticket:write']], + validationContext: ['groups' => ['Default', 'finalize']], + collectDenormalizationErrors: true, + provider: WeighingTicketProvider::class, + processor: WeighingTicketProcessor::class, + ), // Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx). ], )] @@ -146,14 +169,20 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; + /** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */ + public const string STATUS_DRAFT = 'DRAFT'; + + /** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */ + public const string STATUS_VALIDATED = 'VALIDATED'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['weighing_ticket:read'])] private ?int $id = null; - /** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */ - #[ORM\Column(length: 20)] + /** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */ + #[ORM\Column(length: 20, nullable: true)] #[Groups(['weighing_ticket:read'])] private ?string $number = null; @@ -163,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface #[Groups(['weighing_ticket:item:read'])] private ?Site $site = null; - /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */ - #[ORM\Column(name: 'counterparty_type', length: 12)] - #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')] + /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */ + #[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)] + #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])] #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?string $counterpartyType = null; @@ -188,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?string $otherLabel = null; - /** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */ - #[ORM\Column(length: 20)] - #[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')] + /** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */ + #[ORM\Column(length: 20, nullable: true)] + #[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])] #[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $immatriculation = null; @@ -210,13 +239,11 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface /** * Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). - * Obligatoire : un ticket est cree APRES la pesee a vide (POST). NotBlank ici - * (et non sur empty_dsd, alloue serveur) rend la 422 « poids obligatoire » - * coherente avec les autres champs requis (counterpartyType / immatriculation), - * toutes renvoyees d'un coup -> mapping inline front (ERP-101). + * Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord, + * ERP-193). L'obligation des DEUX pesees est portee par validateFinalization + * (groupe `finalize`), jouee uniquement a la validation. */ #[ORM\Column(name: 'empty_weight', nullable: true)] - #[Assert\NotBlank(message: 'Le poids est obligatoire : effectuez une pesée.')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $emptyWeight = null; @@ -268,6 +295,16 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface #[Groups(['weighing_ticket:read'])] private ?int $netWeight = null; + /** + * Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans + * contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose + * serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de + * groupe d'ecriture (jamais pilote par le client). + */ + #[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])] + #[Groups(['weighing_ticket:read'])] + private string $status = self::STATUS_DRAFT; + /** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */ #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] private ?DateTimeImmutable $deletedAt = null; @@ -284,7 +321,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface * (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les * champs hors-branche — ERP-185). */ - #[Assert\Callback] + #[Assert\Callback(groups: ['finalize'])] public function validateCounterpartyConsistency(ExecutionContextInterface $context): void { switch ($this->counterpartyType) { @@ -320,6 +357,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface } } + /** + * Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec + * ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que + * complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ; + * un brouillon peut ne porter qu'une seule pesee. Violations posees sur les + * champs poids -> mapping inline front (useFormErrors, ERP-101). + */ + #[Assert\Callback(groups: ['finalize'])] + public function validateFinalization(ExecutionContextInterface $context): void + { + if (null === $this->emptyWeight) { + $context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.') + ->atPath('emptyWeight') + ->addViolation() + ; + } + + if (null === $this->fullWeight) { + $context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.') + ->atPath('fullWeight') + ->addViolation() + ; + } + } + /** * Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si * disponible, sinon date de la pesee a vide. Getter calcule (jamais @@ -568,6 +630,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface return $this; } + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function isValidated(): bool + { + return self::STATUS_VALIDATED === $this->status; + } + public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php index 984a298..1cbb344 100644 --- a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/WeighingTicketProcessor.php @@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } - // Une entite non geree par l'ORM = creation (POST) : site + numero ne sont - // attribues qu'a ce moment et restent immuables ensuite (RG-5.09). + // Une entite non geree par l'ORM = creation (POST). On rattache le site + // courant (cloisonnement + base de la numerotation), immuable ensuite + // (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon » + // (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193). $isNew = !$this->em->contains($data); if ($isNew) { - $site = $this->resolveCurrentSite(); - $data->setSite($site); - $data->setNumber($this->numberAllocator->allocate($site)); + $data->setSite($this->resolveCurrentSite()); } $this->applyCounterpartyExclusivity($data); @@ -89,6 +89,18 @@ final class WeighingTicketProcessor implements ProcessorInterface $this->computeNetWeight($data); + // Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide. + // La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a + // deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur + // verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on + // passe le statut a VALIDATED. + if ('weighing_ticket_validate' === $operation->getName()) { + if (null === $data->getNumber() && $site instanceof Site) { + $data->setNumber($this->numberAllocator->allocate($site)); + } + $data->setStatus(WeighingTicket::STATUS_VALIDATED); + } + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index eec268c..666496c 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -556,12 +556,12 @@ final class ColumnCommentsCatalog '_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.', 'id' => 'Identifiant interne auto-incremente.', 'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).', - 'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).', - 'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.', + 'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).', + 'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. Pilote l obligation client_id / supplier_id / other_label.', 'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).', 'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).', 'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).', - 'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).', + 'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).', 'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.', 'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.', 'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).', @@ -574,6 +574,7 @@ final class ColumnCommentsCatalog 'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).', 'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).', 'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.', + 'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.', 'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.', ] + self::timestampableBlamableComments(), ]; diff --git a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php index 42c3039..519c6bf 100644 --- a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php +++ b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php @@ -220,7 +220,8 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase /** * POST un ticket et renvoie la reponse (assertions de statut a la charge de - * l'appelant). + * l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la + * validation est portee par validateTicket(). */ protected function postTicket(Client $http, array $payload): ResponseInterface { @@ -230,6 +231,32 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase ]); } + /** + * « Valider » un ticket : PATCH /weighing_tickets/{id}/validate (ERP-193). + * Declenche la validation stricte (groupe finalize) + attribution du numero + + * passage en VALIDATED. Body vide par defaut = on valide l'etat deja persiste. + */ + protected function validateTicket(Client $http, int $id, array $payload = []): ResponseInterface + { + return $http->request('PATCH', '/api/weighing_tickets/'.$id.'/validate', [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [] === $payload ? new \stdClass() : $payload, + ]); + } + + /** + * POST un brouillon complet puis le valide ; renvoie le ticket VALIDE (numero + * attribue). Le payload doit porter contrepartie + immatriculation + 2 pesees. + * + * @return array + */ + protected function createValidatedTicket(Client $http, array $payload): array + { + $id = (int) $this->postTicket($http, $payload)->toArray()['id']; + + return $this->validateTicket($http, $id)->toArray(); + } + /** * Retrouve un membre d'une collection Hydra par son id. * diff --git a/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php new file mode 100644 index 0000000..0101bfb --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketLifecycleTest.php @@ -0,0 +1,92 @@ + valide du ticket de pesee (ERP-193, spec-back § 2.14). + * + * Couvre : + * - une pesee peut etre enregistree SANS contrepartie ni immatriculation : le POST + * cree un BROUILLON (status DRAFT, pas de numero) ; + * - la validation (PATCH /validate) exige les 3 champs du haut (type + champ + * contrepartie + immatriculation) ET les 2 pesees (groupe `finalize`) ; + * - une validation complete attribue le numero {siteCode}-TP-{NNNN} et passe le + * ticket en VALIDATED. + * + * @internal + */ +final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCase +{ + public function testWeighingOnlyCreatesDraftWithoutNumber(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Pesee a vide seule : ni contrepartie, ni immatriculation. + $body = $this->postTicket($http, [ + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('DRAFT', $body['status']); + self::assertArrayNotHasKey('number', $body, 'Un brouillon n\'a pas encore de numero (skip_null_values).'); + self::assertSame(7150, $body['emptyWeight']); + } + + public function testValidateRequiresCounterparty(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + + // Brouillon complet cote pesees + immatriculation, mais SANS contrepartie. + $id = (int) $this->postTicket($http, [ + 'immatriculation' => 'AB-123-CD', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + 'fullDate' => '2026-06-17T09:12:00+02:00', + 'fullWeight' => 14300, + 'fullMode' => 'AUTO', + ])->toArray()['id']; + + $response = $this->validateTicket($http, $id); + + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'counterpartyType'); + } + + public function testValidateRequiresBothWeighings(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + $client = $this->seedTestClient('Lifecycle'); + + // Brouillon avec contrepartie + immat + UNE seule pesee (a vide). + $id = (int) $this->postTicket($http, [ + 'counterpartyType' => 'CLIENT', + 'client' => $this->clientIri($client), + 'immatriculation' => 'AB-123-CD', + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + ])->toArray()['id']; + + $response = $this->validateTicket($http, $id); + + self::assertResponseStatusCodeSame(422); + self::assertViolationOnPath($response, 'fullWeight'); + } + + public function testValidateAssignsNumberAndStatus(): void + { + $http = $this->authManageOnSite($this->siteByCode('86')); + $client = $this->seedTestClient('LifecycleOk'); + + $validated = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); + + self::assertSame('VALIDATED', $validated['status']); + self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', (string) $validated['number']); + self::assertSame(7150, $validated['netWeight']); + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php index 32e3bfd..33fe6a2 100644 --- a/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php @@ -26,13 +26,12 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http = $this->authManageOnSite($site); $client = $this->seedTestClient('Num'); - $first = $this->postTicket($http, $this->validClientTicketPayload($client)); - self::assertResponseStatusCodeSame(201); - $second = $this->postTicket($http, $this->validClientTicketPayload($client)); - self::assertResponseStatusCodeSame(201); + // Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193). + $first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); + $second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); - $n1 = (string) $first->toArray()['number']; - $n2 = (string) $second->toArray()['number']; + $n1 = (string) $first['number']; + $n2 = (string) $second['number']; self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1); self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2); @@ -49,8 +48,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http86 = $this->authManageOnSite($this->siteByCode('86')); $http17 = $this->authManageOnSite($this->siteByCode('17')); - $n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number']; - $n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number']; + $n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number']; + $n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number']; // Chaque site encode son propre code dans le numero ; sequences disjointes. self::assertStringStartsWith('86-TP-', $n86); @@ -63,7 +62,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas $http = $this->authManageOnSite($site); $client = $this->seedTestClient('Immutable'); - $created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray(); + // Ticket valide (numero attribue) puis tentative de re-ecriture. + $created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client)); $id = (int) $created['id']; $number = (string) $created['number']; diff --git a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php index c459d18..94e0b67 100644 --- a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php @@ -31,12 +31,12 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick $http = $this->authManageOnSite($site); $clientEntity = $this->seedTestClient('Negoce'); - $created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); - self::assertResponseStatusCodeSame(201); - $createdBody = $created->toArray(); + // Brouillon cree puis valide (numero attribue a la validation, ERP-193). + $createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity)); $id = (int) $createdBody['id']; $number = (string) $createdBody['number']; + self::assertSame('VALIDATED', $createdBody['status']); $detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); $list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray(); @@ -69,6 +69,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick // displayDate (date du ticket = fullDate ?? emptyDate) expose en liste. self::assertArrayHasKey('displayDate', $row); + // Statut du cycle de vie expose en liste (colonne « En attente / Terminée »). + self::assertSame('VALIDATED', $row['status']); + // === DETAIL : site embarque (avec code), immatriculation, les 2 pesees === self::assertIsArray($detail['site']); self::assertSame('86', $detail['site']['code']); @@ -95,9 +98,7 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick $http = $this->authManageOnSite($site); $supplierEntity = $this->seedTestSupplier('Ferraille'); - $created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity)); - self::assertResponseStatusCodeSame(201); - $createdBody = $created->toArray(); + $createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity)); $id = (int) $createdBody['id']; $number = (string) $createdBody['number']; diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php index ee972f7..828354e 100644 --- a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php @@ -145,14 +145,17 @@ final class CounterpartyValidationTest extends TestCase } /** - * Liste des propertyPath des violations de l'entite. + * Liste des propertyPath des violations de l'entite, validee dans le groupe + * `finalize` (la coherence contrepartie ne joue qu'a la validation depuis + * ERP-193 ; un brouillon peut ne pas porter de contrepartie). Miroir du + * validationContext de l'operation `validate` (['Default', 'finalize']). * * @return list */ private function violationPaths(WeighingTicket $ticket): array { $paths = []; - foreach ($this->validator->validate($ticket) as $violation) { + foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) { $paths[] = $violation->getPropertyPath(); } -- 2.39.5 From 31678cb7162f6dd54ac8baf70a56ebad37a9d9a6 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 15:15:25 +0200 Subject: [PATCH 15/20] =?UTF-8?q?feat(back)=20:=20export=20tickets=20de=20?= =?UTF-8?q?pes=C3=A9e=20=E2=80=94=20colonnes=20Fournisseur/Client/Autre=20?= =?UTF-8?q?+=20Statut=20(ERP-193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace les colonnes « Type contrepartie » + « Contrepartie » par 3 colonnes mutuellement exclusives Fournisseur / Client / Autre (miroir de la liste), et ajoute une colonne Statut (« En attente » / « Terminée »). --- .../WeighingTicketExportController.php | 50 ++++++++----------- .../WeighingTicketExportControllerTest.php | 15 ++++-- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php b/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php index f15cd56..5e0ad85 100644 --- a/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php +++ b/src/Module/Logistique/Infrastructure/Controller/WeighingTicketExportController.php @@ -146,8 +146,11 @@ final class WeighingTicketExportController { return [ 'Numéro', - 'Type contrepartie', - 'Contrepartie', + // Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de + // la liste / repertoire, ERP-193) plutot que « type + nom ». + 'Fournisseur', + 'Client', + 'Autre', 'Date', 'Immatriculation', 'Poids vide (kg)', @@ -155,6 +158,7 @@ final class WeighingTicketExportController 'Poids net (kg)', 'DSD vide', 'DSD plein', + 'Statut', ]; } @@ -166,10 +170,14 @@ final class WeighingTicketExportController private function buildRows(array $tickets): iterable { foreach ($tickets as $ticket) { + $type = $ticket->getCounterpartyType(); + yield [ - $ticket->getNumber(), - $this->counterpartyTypeLabel($ticket->getCounterpartyType()), - $this->counterpartyName($ticket), + $ticket->getNumber() ?? '', + // Une seule des 3 colonnes est renseignee selon le type (RG-5.03). + 'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '', + 'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '', + 'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '', $ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '', $ticket->getImmatriculation() ?? '', $ticket->getEmptyWeight() ?? '', @@ -177,36 +185,22 @@ final class WeighingTicketExportController $ticket->getNetWeight() ?? '', $ticket->getEmptyDsd() ?? '', $ticket->getFullDsd() ?? '', + $this->statusLabel($ticket->getStatus()), ]; } } /** - * Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour - * une valeur inattendue (garde-fou : ne masque pas une donnee corrompue). + * Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou + * « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue + * (garde-fou : ne masque pas une donnee corrompue). */ - private function counterpartyTypeLabel(?string $type): string + private function statusLabel(string $status): string { - return match ($type) { - 'CLIENT' => 'Client', - 'FOURNISSEUR' => 'Fournisseur', - 'AUTRE' => 'Autre', - default => $type ?? '', - }; - } - - /** - * Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client, - * du fournisseur, ou libelle libre « Autre ». Client / Supplier sont - * fetch-joines par le repository (anti N+1, § 4.0). - */ - private function counterpartyName(WeighingTicket $ticket): string - { - return match ($ticket->getCounterpartyType()) { - 'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '', - 'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '', - 'AUTRE' => $ticket->getOtherLabel() ?? '', - default => '', + return match ($status) { + WeighingTicket::STATUS_DRAFT => 'En attente', + WeighingTicket::STATUS_VALIDATED => 'Terminée', + default => $status, }; } diff --git a/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php b/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php index 4f967df..bc372b4 100644 --- a/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketExportControllerTest.php @@ -72,8 +72,10 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase // 1re ligne = en-tetes attendus (ordre des colonnes § 4.5). $header = $this->gridFromResponse($response->getContent())[0]; self::assertSame('Numéro', $header[0]); - self::assertContains('Type contrepartie', $header); - self::assertContains('Contrepartie', $header); + // Contrepartie eclatee en 3 colonnes (miroir liste, ERP-193). + self::assertContains('Fournisseur', $header); + self::assertContains('Client', $header); + self::assertContains('Autre', $header); self::assertContains('Date', $header); self::assertContains('Immatriculation', $header); self::assertContains('Poids vide (kg)', $header); @@ -81,6 +83,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase self::assertContains('Poids net (kg)', $header); self::assertContains('DSD vide', $header); self::assertContains('DSD plein', $header); + self::assertContains('Statut', $header); } /** @@ -99,8 +102,11 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase $cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null; - self::assertSame('Client', $cell('Type contrepartie')); - self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie')); + // Contrepartie Client → colonne « Client » renseignée, « Fournisseur » / « Autre » vides. + self::assertStringContainsString('BÉTON SA', (string) $cell('Client')); + self::assertSame('', (string) $cell('Fournisseur')); + self::assertSame('', (string) $cell('Autre')); + self::assertSame('Terminée', $cell('Statut')); self::assertSame('AB-123-CD', $cell('Immatriculation')); self::assertSame(7150, (int) $cell('Poids vide (kg)')); self::assertSame(14300, (int) $cell('Poids plein (kg)')); @@ -184,6 +190,7 @@ final class WeighingTicketExportControllerTest extends AbstractApiTestCase $ticket->setFullDsd(42); $ticket->setFullMode('AUTO'); $ticket->setNetWeight(7150); + $ticket->setStatus(WeighingTicket::STATUS_VALIDATED); $em->persist($ticket); $em->flush(); -- 2.39.5 From 9e2206a7d6d8171e5c017e1937321d2132cc08bc Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 15:33:12 +0200 Subject: [PATCH 16/20] =?UTF-8?q?fix=20:=20DSD=20saisi=20conserv=C3=A9=20e?= =?UTF-8?q?n=20pes=C3=A9e=20manuelle=20(ERP-193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En pesée manuelle, le serveur incrémentait automatiquement le DSD et ignorait la saisie de l'opérateur. Désormais l'opérateur saisit le poids ET le DSD (le numéro du pont réellement utilisé), conservés tels quels — plus d'auto-incrément. Le champ « Numéro de pesée » séparé (manualNumber) est supprimé : pour le client c'est la même chose que le DSD. Pas de contrainte d'unicité sur le DSD (doublons autorisés). Colonnes empty_manual_number/full_manual_number droppées. --- frontend/i18n/locales/fr.json | 4 +- .../__tests__/useWeighbridge.spec.ts | 11 +++-- .../__tests__/useWeighingTicketForm.spec.ts | 7 +-- .../logistique/composables/useWeighbridge.ts | 14 +++--- .../composables/useWeighingTicket.ts | 2 - .../composables/useWeighingTicketForm.ts | 13 +---- .../pages/weighing-tickets/[id]/edit.vue | 21 ++++---- .../logistique/pages/weighing-tickets/new.vue | 21 ++++---- migrations/Version20260624110000.php | 41 ++++++++++++++++ .../Domain/Entity/WeighingTicket.php | 36 -------------- .../Resource/WeighbridgeReadingResource.php | 48 +++++++++++-------- .../Processor/WeighbridgeReadingProcessor.php | 18 ++++--- .../Database/ColumnCommentsCatalog.php | 44 ++++++++--------- .../weighing_ticket_print.html.twig | 11 ++--- .../Api/WeighbridgeReadingApiTest.php | 27 +++++++---- .../WeighbridgeReadingProcessorTest.php | 39 +++++---------- 16 files changed, 175 insertions(+), 182 deletions(-) create mode 100644 migrations/Version20260624110000.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 95e6485..56e766d 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -741,10 +741,10 @@ "manual": { "title": "Pesée manuelle", "weight": "Poids (Kg)", - "number": "Numéro de pesée", + "dsd": "DSD", "save": "Enregistrer", "weightRequired": "Le poids est obligatoire.", - "numberRequired": "Le numéro de pesée est obligatoire." + "dsdRequired": "Le DSD est obligatoire." } }, "edit": { diff --git a/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts index 8ff8e72..2004b50 100644 --- a/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts +++ b/frontend/modules/logistique/composables/__tests__/useWeighbridge.spec.ts @@ -26,18 +26,19 @@ describe('useWeighbridge', () => { 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' }) + it('MANUAL : POST { mode: MANUAL, weight, dsd } et renvoie la lecture', async () => { + // Le DSD est saisi par l'opérateur et conservé tel quel (ERP-193). + mockPost.mockResolvedValue({ weight: 5000, dsd: 16619, mode: 'MANUAL' }) const { triggerManual } = useWeighbridge() - const reading = await triggerManual(5000, 'PAP-555') + const reading = await triggerManual(5000, 16619) expect(mockPost).toHaveBeenCalledWith( '/weighbridge_readings', - { mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' }, + { mode: 'MANUAL', weight: 5000, dsd: 16619 }, expect.objectContaining({ toast: false }), ) - expect(reading.dsd).toBe(43) + expect(reading.dsd).toBe(16619) }) it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => { diff --git a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts index de61652..f820710 100644 --- a/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts +++ b/frontend/modules/logistique/composables/__tests__/useWeighingTicketForm.spec.ts @@ -106,11 +106,12 @@ describe('useWeighingTicketForm', () => { 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' }) + // Pesée manuelle : le DSD saisi (16619) est conservé tel quel (ERP-193). + form.applyReading(form.full, { weight: 14300, dsd: 16619, mode: 'MANUAL' }) expect(form.full.weight).toBe(14300) - expect(form.full.manualNumber).toBe('PAP-555') + expect(form.full.dsd).toBe(16619) + expect(form.full.mode).toBe('MANUAL') }) it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => { diff --git a/frontend/modules/logistique/composables/useWeighbridge.ts b/frontend/modules/logistique/composables/useWeighbridge.ts index 2f08b19..a582f76 100644 --- a/frontend/modules/logistique/composables/useWeighbridge.ts +++ b/frontend/modules/logistique/composables/useWeighbridge.ts @@ -7,8 +7,8 @@ * - 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). + * - MANUAL (« Pesée manuelle ») : poids + DSD saisis par l'opérateur ; le serveur + * les conserve tels quels — plus d'auto-incrément (ERP-193). * * 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 @@ -24,8 +24,6 @@ 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() { @@ -46,13 +44,13 @@ export function useWeighbridge() { } /** - * 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. + * Pesée manuelle (MANUAL). Le poids ET le DSD sont saisis par l'opérateur (le + * DSD = numéro du pont réellement utilisé) et conservés tels quels (ERP-193). */ - async function triggerManual(weight: number, manualNumber: string): Promise { + async function triggerManual(weight: number, dsd: number): Promise { return await api.post( '/weighbridge_readings', - { mode: 'MANUAL', weight, manualNumber }, + { mode: 'MANUAL', weight, dsd }, { toast: false }, ) } diff --git a/frontend/modules/logistique/composables/useWeighingTicket.ts b/frontend/modules/logistique/composables/useWeighingTicket.ts index 6865c8e..52cdea9 100644 --- a/frontend/modules/logistique/composables/useWeighingTicket.ts +++ b/frontend/modules/logistique/composables/useWeighingTicket.ts @@ -25,13 +25,11 @@ export interface WeighingTicketDetail { 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 } diff --git a/frontend/modules/logistique/composables/useWeighingTicketForm.ts b/frontend/modules/logistique/composables/useWeighingTicketForm.ts index 04c490c..7f4c63f 100644 --- a/frontend/modules/logistique/composables/useWeighingTicketForm.ts +++ b/frontend/modules/logistique/composables/useWeighingTicketForm.ts @@ -32,12 +32,10 @@ export interface WeighingBlockState { 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 — pesée bascule : fourni par le pont ; pesée manuelle : saisi (RG-5.04, ERP-193). */ 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 } /** Cycle de vie du ticket (miroir back, ERP-193). */ @@ -57,12 +55,10 @@ export interface WeighingTicketHydration { 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 } /** @@ -95,7 +91,6 @@ function emptyBlock(now: string): WeighingBlockState { weight: null, dsd: null, mode: null, - manualNumber: null, } } @@ -172,13 +167,12 @@ export function useWeighingTicketForm() { */ function applyReading( block: WeighingBlockState, - reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string }, + reading: { weight: number, dsd: number, mode: WeighbridgeMode }, ): void { block.date = nowIsoDateTime() 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). */ @@ -203,7 +197,6 @@ export function useWeighingTicketForm() { [`${prefix}Weight`]: block.weight, [`${prefix}Dsd`]: block.dsd, [`${prefix}Mode`]: block.mode, - [`${prefix}ManualNumber`]: block.manualNumber || null, } } @@ -245,13 +238,11 @@ export function useWeighingTicketForm() { empty.weight = detail.emptyWeight ?? null empty.dsd = detail.emptyDsd ?? null empty.mode = detail.emptyMode ?? null - empty.manualNumber = detail.emptyManualNumber ?? null full.date = toLocalIsoDateTime(detail.fullDate) ?? now full.weight = detail.fullWeight ?? null full.dsd = detail.fullDsd ?? null full.mode = detail.fullMode ?? null - full.manualNumber = detail.fullManualNumber ?? null } /** diff --git a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue index 140ef64..1b7085b 100644 --- a/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue +++ b/frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue @@ -166,10 +166,11 @@ :error="manualModal.errors.weight" />