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