fix(front) : ajustements du formulaire ticket de pesée (ERP-189/190)

- 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).
This commit is contained in:
2026-06-23 14:03:32 +02:00
parent 68e7205793
commit 5349c3c4d5
7 changed files with 200 additions and 66 deletions
+2
View File
@@ -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",
@@ -3,15 +3,19 @@
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
<div class="flex items-center gap-4">
<div class="flex items-center gap-8">
<MalioButton
variant="secondary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
:disabled="disabled"
@click="$emit('request-auto')"
/>
<MalioButton
variant="primary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
:disabled="disabled"
@click="$emit('request-manual')"
@@ -19,64 +23,76 @@
</div>
</div>
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
<slot name="counterparty" />
<div class="mt-6 flex flex-col gap-4">
<!-- Ligne 1 : contrepartie (type en col 1 + champ conditionnel en col 2),
rendue par le parent (bloc vide uniquement) via le slot. -->
<div v-if="$slots.counterparty" class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<slot name="counterparty" />
</div>
<!-- Date de la pesée jour par défaut (RG-5.07). MalioDate (composant
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
<MalioDate
:model-value="block.date"
:label="t('logistique.weighingTickets.form.date')"
:required="true"
:editable="true"
:disabled="disabled"
:error="errors.date"
@update:model-value="(v: string | null) => emitBlock('date', v)"
/>
<!-- Ligne 2 : Date, Poids, DSD, Immatriculation. -->
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Date de la pesée jour par défaut (RG-5.07). MalioDate (composant
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
<MalioDate
:model-value="block.date"
:label="t('logistique.weighingTickets.form.date')"
:required="true"
:editable="true"
:disabled="disabled"
:error="errors.date"
@update:model-value="(v: string | null) => emitBlock('date', v)"
/>
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
<MalioInputNumber
:model-value="block.weight"
:label="t('logistique.weighingTickets.form.weight')"
:required="true"
:readonly="true"
:disabled="disabled"
:error="errors.weight"
/>
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
(rempli par la pesée, jamais saisi à la main — RG-5.07). Unité Kg
dans le label. -->
<MalioInputText
:model-value="weightDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.weight')"
:required="true"
:disabled="true"
:error="errors.weight"
/>
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
<MalioInputNumber
:model-value="block.dsd"
:label="t('logistique.weighingTickets.form.dsd')"
:required="true"
:readonly="true"
:disabled="disabled"
:error="errors.dsd"
/>
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
(rempli par la pesée — RG-5.04 / RG-5.07). -->
<MalioInputText
:model-value="dsdDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.dsd')"
:required="true"
:disabled="true"
:error="errors.dsd"
/>
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
<MalioInputText
:model-value="immatriculation"
:mask="plateFreeFormat ? undefined : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:disabled="disabled"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
/>
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
<MalioInputText
:model-value="immatriculation"
:mask="plateFreeFormat ? undefined : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:disabled="disabled"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
/>
</div>
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
<MalioCheckbox
:id="`${blockId}-plate-free-format`"
:model-value="plateFreeFormat"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
:disabled="disabled"
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
/>
<!-- Ligne 3 : « Tout format » (désactive le masque plaque). Partagé entre
blocs (RG-5.01). Sur sa propre ligne. -->
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioCheckbox
:id="`${blockId}-plate-free-format`"
:model-value="plateFreeFormat"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
:disabled="disabled"
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
/>
</div>
</div>
</div>
</template>
@@ -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)
@@ -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()
@@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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,
@@ -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
@@ -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<Record<string, string>>(() => ({
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<Record<string, string>>(() => ({
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<void> {
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
async function submitSave(): Promise<void> {
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')
@@ -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<Record<string, string>>(() => ({
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<Record<string, string>>(() => ({
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<void> {
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<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
headers: { Accept: 'application/ld+json' },
@@ -351,8 +375,10 @@ async function submitCreate(): Promise<void> {
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
async function submitValidate(): Promise<void> {
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