+
-
-
-
+
+
+
+
+
-
-
emitBlock('date', v)"
- />
+
+
+
+ emitBlock('date', v)"
+ />
-
-
+
+
-
-
+
+
-
- $emit('update:immatriculation', v)"
- />
+
+ $emit('update:immatriculation', v)"
+ />
+
-
- $emit('update:plateFreeFormat', v)"
- />
+
+
+ $emit('update:plateFreeFormat', v)"
+ />
+
@@ -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