From 4dcc24743628d13d262d0c1f62c9b21b5b3de215 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 22 Jun 2026 16:13:30 +0200 Subject: [PATCH 01/12] =?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 02/12] =?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 03/12] =?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 04/12] =?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 05/12] =?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 11/12] =?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 12/12] =?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" />