feat : M5 — Tickets de pesée (ERP-188 → ERP-193) (#144)
Auto Tag Develop / tag (push) Successful in 8s

MR unique regroupant tout le module M5 « Tickets de pesée » (remplace les MR empilées #140/#141/#142/#143).

## Périmètre
- **ERP-188** — Page liste des tickets de pesée + export XLSX (colonnes Fournisseur/Client/Autre + Statut).
- **ERP-189** — Écran « Ajouter » (4 champs en haut, 2 blocs de pesée, pesée bascule/manuelle, date+heure horodatée à la validation).
- **ERP-190** — Écran « Modifier » + bouton Imprimer.
- **ERP-191** — i18n + libellés + branchement site courant.
- **ERP-192** — Bon de pesée PDF généré côté back (template Twig → Dompdf), endpoint `GET /api/weighing_tickets/{id}/print.pdf`.
- **ERP-193** — Cycle de vie brouillon/validé (status DRAFT/VALIDATED, numéro attribué à la validation), DSD saisi conservé en pesée manuelle, retours métier design.

## Vérifications
- Back : tests Logistique + architecture verts, php-cs-fixer propre, migrations appliquées (dev + test).
- Front : suite Vitest complète verte, ESLint propre.

Base : `develop` — contient les 16 commits du M5 (rien d'autre).
Reviewed-on: #144
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #144.
This commit is contained in:
2026-06-24 14:38:01 +00:00
committed by Autin
parent a4158d4e37
commit faafd99ef8
47 changed files with 4121 additions and 254 deletions
@@ -0,0 +1,109 @@
<template>
<!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
<div>
<!-- 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-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')"
/>
</div>
</div>
<!-- Ligne : Date/heure, Poids, DSD. L'immatriculation et « Tout format »
vivent désormais dans les 4 champs du haut, hors des blocs (ERP-193). -->
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Date/heure de la pesée — date du jour + heure courante par défaut
(RG-5.07), ré-horodatée à la validation de la pesée. -->
<MalioDateTime
: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 : champ texte verrouillé sur les chiffres, toujours désactivé
(rempli par la pesée, jamais saisi à la main — RG-5.07). -->
<MalioInputText
:model-value="weightDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.weight')"
:required="true"
:disabled="true"
:error="errors.weight"
/>
<!-- 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"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
/**
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
* Champs Date/heure / Poids / DSD + boutons de pesée (bascule / manuelle). Depuis
* ERP-193, la contrepartie, l'immatriculation et « Tout format » sont remontés dans
* les 4 champs du haut de page (hors blocs). Masque numérique factorisé dans
* `utils/weighingMasks`.
*/
const props = withDefaults(defineProps<{
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
blockId: string
title: string
block: WeighingBlockState
/** Erreurs 422 par champ (propertyPath → message). */
errors?: Record<string, string>
disabled?: boolean
}>(), {
errors: () => ({}),
disabled: false,
})
const emit = defineEmits<{
'update:block': [field: keyof WeighingBlockState, value: unknown]
'request-auto': []
'request-manual': []
}>()
const { t } = useI18n()
// 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)
}
</script>
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
const mockPost = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ post: mockPost }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
const { useWeighbridge } = await import('../useWeighbridge')
describe('useWeighbridge', () => {
beforeEach(() => {
mockPost.mockReset()
})
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
const { triggerAuto } = useWeighbridge()
const reading = await triggerAuto()
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'AUTO' },
expect.objectContaining({ toast: false }),
)
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
})
it('MANUAL : POST { mode: MANUAL, weight, 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, 16619)
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'MANUAL', weight: 5000, dsd: 16619 },
expect.objectContaining({ toast: false }),
)
expect(reading.dsd).toBe(16619)
})
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
const { extractWeighbridgeError } = useWeighbridge()
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
})
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
const { extractWeighbridgeError } = useWeighbridge()
expect(extractWeighbridgeError(new Error('network')))
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
})
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
mockPost.mockRejectedValue({ response: { status: 503 } })
const { triggerAuto } = useWeighbridge()
await expect(triggerAuto()).rejects.toBeDefined()
})
})
@@ -0,0 +1,228 @@
import { describe, it, expect, vi } from 'vitest'
// `nowIsoDateTime` est importé par le composable : on le stubbe pour un instant déterministe.
vi.mock('~/shared/utils/date', () => ({ nowIsoDateTime: () => '2026-06-22T08:30:00' }))
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
describe('useWeighingTicketForm', () => {
it('initialise les 2 blocs à la date/heure courante (RG-5.07), sans poids ni DSD', () => {
const form = useWeighingTicketForm()
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.full.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull()
expect(form.empty.dsd).toBeNull()
expect(form.counterpartyType.value).toBeNull()
})
// ── Omission des requis vides (compact) ──────────────────────────────────
it('buildDraftPayload : brouillon vierge → pas de champ requis ni de bloc non pesé', () => {
const form = useWeighingTicketForm()
// Formulaire vierge : counterpartyType / immatriculation non remplis, aucune pesée.
const payload = form.buildDraftPayload()
// Absents (et non null) → le back laisse jouer les contraintes du groupe finalize.
expect(payload).not.toHaveProperty('counterpartyType')
expect(payload).not.toHaveProperty('immatriculation')
// Bloc non pesé → ni poids ni date (on n'envoie pas une date de pesée sans pesée).
expect(payload).not.toHaveProperty('emptyWeight')
expect(payload).not.toHaveProperty('emptyDate')
// Seul le booléen « Tout format » reste.
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()
form.supplierIri.value = '/api/suppliers/3'
form.otherLabel.value = 'Particulier'
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/629'
expect(form.counterpartyField.value).toBe('client')
expect(form.supplierIri.value).toBeNull()
expect(form.otherLabel.value).toBeNull()
const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload).not.toHaveProperty('supplier')
expect(payload).not.toHaveProperty('otherLabel')
})
it('FOURNISSEUR : ne conserve que le supplier', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('FOURNISSEUR')
form.supplierIri.value = '/api/suppliers/7'
expect(form.counterpartyField.value).toBe('supplier')
expect(form.clientIri.value).toBeNull()
expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
})
it('AUTRE : ne conserve que le libellé libre', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('AUTRE')
form.otherLabel.value = 'Reprise interne'
expect(form.counterpartyField.value).toBe('other')
expect(form.clientIri.value).toBeNull()
expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne')
})
it('buildDraftPayload : type choisi mais champ associé vide → contrepartie omise (pas de 500 chk_wt_*_branch)', () => {
const form = useWeighingTicketForm()
// L'opérateur ouvre le menu « Client » mais n'a pas encore choisi le client.
form.setCounterpartyType('CLIENT')
const draft = form.buildDraftPayload()
// On n'émet ni le type ni la FK : un brouillon incohérent serait rejeté en 500 par le back.
expect(draft).not.toHaveProperty('counterpartyType')
expect(draft).not.toHaveProperty('client')
// En revanche la validation envoie toujours le type, pour déclencher la 422 métier.
expect(form.buildValidatePayload().counterpartyType).toBe('CLIENT')
})
it('buildDraftPayload : AUTRE avec libellé vide → contrepartie omise', () => {
const form = useWeighingTicketForm()
form.setCounterpartyType('AUTRE')
form.otherLabel.value = ' '
const draft = form.buildDraftPayload()
expect(draft).not.toHaveProperty('counterpartyType')
expect(draft).not.toHaveProperty('otherLabel')
})
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
const form = useWeighingTicketForm()
form.immatriculation.value = 'AB-123-CD'
form.plateFreeFormat.value = true
// Les 2 payloads (brouillon + validation) reflètent la même valeur.
expect(form.buildDraftPayload().immatriculation).toBe('AB-123-CD')
expect(form.buildDraftPayload().plateFreeFormat).toBe(true)
expect(form.buildValidatePayload().immatriculation).toBe('AB-123-CD')
expect(form.buildValidatePayload().plateFreeFormat).toBe(true)
})
// ── Application d'une lecture de pesée ────────────────────────────────────
it('applyReading remplit poids / DSD / mode et ré-horodate le bloc à l\'instant de la pesée', () => {
const form = useWeighingTicketForm()
// Date périmée (ouverture du formulaire bien avant la pesée).
form.empty.date = '2020-01-01T00:00:00'
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
// La pesée validée ré-horodate le bloc à maintenant (stub 2026-06-22T08:30:00).
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBe(7150)
expect(form.empty.dsd).toBe(1)
expect(form.empty.mode).toBe('AUTO')
// 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.dsd).toBe(16619)
expect(form.full.mode).toBe('MANUAL')
})
it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
const form = useWeighingTicketForm()
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/1'
form.immatriculation.value = 'AB-123-CD'
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
// Le brouillon porte LES DEUX pesées effectuées.
const draft = form.buildDraftPayload()
expect(draft.emptyWeight).toBe(7150)
expect(draft.emptyMode).toBe('AUTO')
expect(draft.fullWeight).toBe(14300)
expect(draft.fullMode).toBe('AUTO')
// La validation ne porte que les 4 champs du haut (pesées déjà persistées).
const validate = form.buildValidatePayload()
expect(validate.counterpartyType).toBe('CLIENT')
expect(validate.client).toBe('/api/clients/1')
expect(validate.immatriculation).toBe('AB-123-CD')
expect(validate).not.toHaveProperty('emptyWeight')
expect(validate).not.toHaveProperty('fullWeight')
})
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
it('hydrate pré-remplit l\'état depuis le détail (datetime ISO ramené en local, heure conservée)', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00',
emptyWeight: 7150,
emptyDsd: 1,
emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00',
fullWeight: 14300,
fullDsd: 2,
fullMode: 'AUTO',
})
expect(form.ticketId.value).toBe(9)
expect(form.counterpartyType.value).toBe('CLIENT')
expect(form.counterpartyField.value).toBe('client')
expect(form.clientIri.value).toBe('/api/clients/629')
expect(form.immatriculation.value).toBe('AB-123-CD')
// Datetime back (avec fuseau) -> local sans fuseau, heure conservée pour MalioDateTime.
expect(form.empty.date).toBe('2026-06-17T09:00:00')
expect(form.full.date).toBe('2026-06-17T09:12:00')
expect(form.empty.weight).toBe(7150)
expect(form.full.weight).toBe(14300)
})
it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => {
const form = useWeighingTicketForm()
form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' })
expect(form.otherLabel.value).toBe('Reprise')
expect(form.supplierIri.value).toBeNull()
expect(form.plateFreeFormat.value).toBe(false)
// Pas de date back -> repli sur l'instant courant (stub 2026-06-22T08:30:00).
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull()
})
it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
status: 'VALIDATED',
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
})
expect(form.status.value).toBe('VALIDATED')
const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload.emptyWeight).toBe(7150)
expect(payload.fullWeight).toBe(14300)
expect(payload.immatriculation).toBe('AB-123-CD')
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useWeighingTicketsRepository, type WeighingTicket } from '../useWeighingTicketsRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire des tickets de pesee (M5, ERP-188).
*
* `useWeighingTicketsRepository` est une fine enveloppe de
* `usePaginatedList<WeighingTicket>` sur `/weighing_tickets`. Les invariants
* generiques de pagination sont deja couverts par `usePaginatedList.test.ts` ;
* on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/weighing_tickets` ;
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination) ;
* - DEFAUT 25 ITEMS/PAGE : la liste etant consultee en volume, le premier
* fetch demande 25 items (et non le defaut 10) — l'utilisateur peut toujours
* rebasculer via le selecteur.
*/
describe('useWeighingTicketsRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de tickets Hydra minimale. */
const PAGE: WeighingTicket[] = [
{
id: 1,
status: 'VALIDATED',
number: '86-TP-0001',
client: { id: 7, companyName: 'ACME' },
supplier: null,
otherLabel: null,
displayDate: '2026-06-17T09:12:00+02:00',
netWeight: 7150,
},
]
it('cible /weighing_tickets en Hydra avec 25 items/page par defaut', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useWeighingTicketsRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/weighing_tickets')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.itemsPerPage.value).toBe(25)
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
})
@@ -0,0 +1,72 @@
/**
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
* n'existe pas encore quand on pèse depuis le formulaire principal.
*
* Deux modes :
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
* - MANUAL (« Pesée manuelle ») : poids + 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
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
*/
/** Mode de pesée — miroir de l'enum back. */
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
export interface WeighbridgeReading {
weight: number
dsd: number
mode: WeighbridgeMode
}
export function useWeighbridge() {
const api = useApi()
const { t } = useI18n()
/**
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
* en toast global.
*/
async function triggerAuto(): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'AUTO' },
{ toast: false },
)
}
/**
* 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, dsd: number): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'MANUAL', weight, dsd },
{ toast: false },
)
}
/**
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
* générique invitant à la pesée manuelle.
*/
function extractWeighbridgeError(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data as
| { detail?: string, title?: string }
| undefined
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
}
return { triggerAuto, triggerManual, extractWeighbridgeError }
}
@@ -0,0 +1,53 @@
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
* lus avec un défaut côté hydratation du formulaire.
*/
export interface WeighingTicketDetail {
id: number
/** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
status?: WeighingTicketStatus
/** Numéro `{siteCode}-TP-{NNNN}` — null tant que brouillon, immuable ensuite (RG-5.09). */
number?: string | null
/** Site rattaché (embarqué) — immuable (RG-5.09). */
site?: { id: number, name: string, code: string } | null
counterpartyType?: CounterpartyType | null
client?: { '@id': string, companyName: string } | null
supplier?: { '@id': string, companyName: string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
// Pesée à vide
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
// Pesée à plein
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
netWeight?: number | null
}
/**
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
*/
export function useWeighingTicket() {
const api = useApi()
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
return await api.get<WeighingTicketDetail>(
`/weighing_tickets/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
return { fetchTicket }
}
@@ -0,0 +1,309 @@
import { computed, reactive, ref } from 'vue'
import { nowIsoDateTime } from '~/shared/utils/date'
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
/**
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
* à plein — qui partagent un même véhicule.
*
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
* Changer de type purge les champs des autres types — aucune donnée fantôme.
* - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
* blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
* - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
* courant (pesée enregistrée dès la validation de sa modale, même sans
* contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
* champs du haut + les 2 pesées sont là, `buildValidatePayload()` finalise via
* `PATCH /weighing_tickets/{id}/validate` (numéro attribué, status VALIDATED).
*
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
*/
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
/** Saisie d'une pesée (bloc vide OU bloc plein). */
export interface WeighingBlockState {
/** Date/heure de la pesée (ISO local `YYYY-MM-DDTHH:mm:ss`) — date du jour + heure courante par défaut (RG-5.07). */
date: string | null
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
weight: number | null
/** DSD — 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
}
/** Cycle de vie du ticket (miroir back, ERP-193). */
export type WeighingTicketStatus = 'DRAFT' | 'VALIDATED'
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
export interface WeighingTicketHydration {
id: number
status?: WeighingTicketStatus
counterpartyType?: CounterpartyType | null
client?: { '@id': string } | null
supplier?: { '@id': string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
}
/**
* Ramène une chaîne ISO datetime du back (`2026-06-17T09:00:00+02:00`) au format
* local `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (secondes, sans fuseau) :
* on garde les 19 premiers caractères (date + heure), on retire l'offset. Null si
* absente.
*/
function toLocalIsoDateTime(value: string | null | undefined): string | null {
return value ? value.slice(0, 19) : 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/heure = maintenant, RG-5.07). */
function emptyBlock(now: string): WeighingBlockState {
return {
date: now,
weight: null,
dsd: null,
mode: null,
}
}
export function useWeighingTicketForm() {
const now = nowIsoDateTime()
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
const counterpartyType = ref<CounterpartyType | null>(null)
const clientIri = ref<string | null>(null)
const supplierIri = ref<string | null>(null)
const otherLabel = ref<string | null>(null)
/**
* Change le type de contrepartie et purge les champs devenus hors-sujet :
* un seul de client / supplier / otherLabel est conservé selon le type
* (RG-5.03 — pas de FK fantôme envoyée au back).
*/
function setCounterpartyType(type: CounterpartyType | null): void {
counterpartyType.value = type
if (type !== 'CLIENT') clientIri.value = null
if (type !== 'FOURNISSEUR') supplierIri.value = null
if (type !== 'AUTRE') otherLabel.value = null
}
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
const immatriculation = ref<string | null>(null)
const plateFreeFormat = ref<boolean>(false)
// ── Les deux pesées ───────────────────────────────────────────────────────
const empty = reactive<WeighingBlockState>(emptyBlock(now))
const full = reactive<WeighingBlockState>(emptyBlock(now))
// Id du ticket persisté (POST du 1er enregistrement de pesée) — pilote ensuite
// les PATCH (brouillon) puis la validation. Null tant que rien n'est persisté.
const ticketId = ref<number | null>(null)
// Cycle de vie courant (DRAFT tant que non validé, ERP-193).
const status = ref<WeighingTicketStatus>('DRAFT')
/**
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
* pour afficher conditionnellement le bon champ (RG-5.03).
*/
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
switch (counterpartyType.value) {
case 'CLIENT': return 'client'
case 'FOURNISSEUR': return 'supplier'
case 'AUTRE': return 'other'
default: return null
}
})
/**
* 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. La pesée étant
* effectuée À CET INSTANT, on (ré)horodate le bloc à maintenant : la date/heure
* du ticket reflète le moment réel de la pesée validée, pas l'ouverture du
* formulaire (RG-5.07).
*/
function applyReading(
block: WeighingBlockState,
reading: { weight: number, dsd: number, mode: WeighbridgeMode },
): void {
block.date = nowIsoDateTime()
block.weight = reading.weight
block.dsd = reading.dsd
block.mode = reading.mode
}
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
function counterpartyPayload(): Record<string, unknown> {
switch (counterpartyType.value) {
case 'CLIENT': return { client: clientIri.value }
case 'FOURNISSEUR': return { supplier: supplierIri.value }
case 'AUTRE': return { otherLabel: otherLabel.value || null }
default: return {}
}
}
/**
* Contrepartie d'un BROUILLON : on n'envoie le type QUE si son champ associé est
* renseigné. Un type sans son champ (l'opérateur a ouvert le menu avant de
* choisir) est une contrepartie incohérente que le back devrait retirer (sinon
* les CHECK chk_wt_*_branch lèvent une 500). On évite donc de l'émettre côté
* front. La cohérence reste exigée à la validation : `buildValidatePayload()`
* envoie toujours le type, pour déclencher la 422 métier sur le champ manquant.
*/
function draftCounterpartyPayload(): Record<string, unknown> {
switch (counterpartyType.value) {
case 'CLIENT':
return clientIri.value ? { counterpartyType: 'CLIENT', client: clientIri.value } : {}
case 'FOURNISSEUR':
return supplierIri.value ? { counterpartyType: 'FOURNISSEUR', supplier: supplierIri.value } : {}
case 'AUTRE':
return otherLabel.value && otherLabel.value.trim() !== ''
? { counterpartyType: 'AUTRE', otherLabel: otherLabel.value }
: {}
default:
return {}
}
}
/**
* Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
* n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
* date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
*/
function blockPayload(prefix: 'empty' | 'full', block: WeighingBlockState): Record<string, unknown> {
if (block.weight === null) return {}
return {
[`${prefix}Date`]: block.date,
[`${prefix}Weight`]: block.weight,
[`${prefix}Dsd`]: block.dsd,
[`${prefix}Mode`]: block.mode,
}
}
/**
* Payload de BROUILLON (POST création / PATCH mise à jour, ERP-193) : l'état
* courant complet (4 champs du haut + pesées effectuées). Aucun champ n'est
* requis ici (le back valide en mode relâché) — une pesée s'enregistre sans
* contrepartie ni immatriculation. Numéro/site/net attribués serveur.
*/
function buildDraftPayload(): Record<string, unknown> {
return compact({
...draftCounterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
...blockPayload('empty', empty),
...blockPayload('full', full),
})
}
/**
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
* Les dates ISO du back (datetime + fuseau) sont ramenées au format local
* `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (heure conservée).
*/
function hydrate(detail: WeighingTicketHydration): void {
ticketId.value = detail.id
status.value = detail.status ?? 'DRAFT'
counterpartyType.value = detail.counterpartyType ?? null
clientIri.value = detail.client?.['@id'] ?? null
supplierIri.value = detail.supplier?.['@id'] ?? null
otherLabel.value = detail.otherLabel ?? null
immatriculation.value = detail.immatriculation ?? null
plateFreeFormat.value = detail.plateFreeFormat ?? false
empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
empty.weight = detail.emptyWeight ?? null
empty.dsd = detail.emptyDsd ?? null
empty.mode = detail.emptyMode ?? null
full.date = toLocalIsoDateTime(detail.fullDate) ?? now
full.weight = detail.fullWeight ?? null
full.dsd = detail.fullDsd ?? null
full.mode = detail.fullMode ?? null
}
/**
* Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
* 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
* sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
* validation stricte (groupe `finalize` : 3 champs requis + 2 pesées) et attribue
* le numéro. Les `propertyPath` des 422 sont mappés inline par useFormErrors.
*/
function buildValidatePayload(): Record<string, unknown> {
return compact({
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
})
}
return {
// contrepartie
counterpartyType,
counterpartyField,
clientIri,
supplierIri,
otherLabel,
setCounterpartyType,
// véhicule partagé
immatriculation,
plateFreeFormat,
// pesées
empty,
full,
applyReading,
missingWeighingFields,
// workflow
ticketId,
status,
hydrate,
buildDraftPayload,
buildValidatePayload,
}
}
@@ -0,0 +1,62 @@
import { ref } from 'vue'
/**
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
*
* Collections récupérées en entier via l'échappatoire `?pagination=false`
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
*
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
*/
/** Option au format attendu par MalioSelect ({ label, value }). */
export interface RefOption {
value: string
label: string
}
interface PartyMember {
'@id': string
companyName: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useWeighingTicketReferentials() {
const api = useApi()
const clients = ref<RefOption[]>([])
const suppliers = ref<RefOption[]>([])
/** Récupère une collection complète (pagination désactivée) en Hydra. */
async function fetchAll(url: string): Promise<PartyMember[]> {
const res = await api.get<{ member?: PartyMember[] }>(
url,
{ pagination: 'false' },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
* faire échouer l'autre).
*/
async function load(): Promise<void> {
await Promise.allSettled([
fetchAll('/clients').then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers').then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
return { clients, suppliers, load }
}
@@ -0,0 +1,72 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
import type { WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
*/
export interface WeighingTicketParty {
id: number
companyName: string | null
}
/**
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
*
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
* `netWeight` = plein vide en kg (RG-5.05).
*/
export interface WeighingTicket {
id: number
/** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
status: WeighingTicketStatus
/** Numero metier `{siteCode}-TP-{NNNN}` — null tant que brouillon (RG-5.02). */
number: string | null
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
client: WeighingTicketParty | null
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
supplier: WeighingTicketParty | null
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
otherLabel: string | null
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
displayDate: string | null
/** Poids net en kg (= plein vide, RG-5.05) — colonne « Poids ». */
netWeight: number | null
}
/**
* Filtres de la liste des tickets de pesee, branches sur les query params de
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
* front n'a pas a envoyer le site.
*/
export interface WeighingTicketFilters {
search?: string
}
/**
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
*
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
*/
export function useWeighingTicketsRepository() {
// Defaut 25 items/page (au lieu de 10) : la liste des tickets de pesee est
// consultee en volume. 25 fait partie des options [10, 25, 50] et reste sous le
// max serveur (50). L'utilisateur peut toujours basculer via le selecteur.
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({
url: '/weighing_tickets',
defaultItemsPerPage: 25,
})
}
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
// pré-remplissage via hydrate). ─────────────────────────────────────────────
const mockFetchTicket = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
}))
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) {
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
},
})
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
// dans les 4 champs du haut, hors bloc (ERP-193).
const BlockStub = defineComponent({
setup() { return () => h('div', { 'data-testid': 'block' }) },
})
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioInputNumber: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
// Monte la page (setup async : top-level await) via Suspense.
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
const DETAIL = {
id: 9,
status: 'VALIDATED',
number: '86-TP-0001',
site: { id: 1, name: 'Chatellerault', code: '86' },
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
}
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
beforeEach(() => {
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
})
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
await mountPage()
expect(mockFetchTicket).toHaveBeenCalledWith('9')
})
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
const wrapper = await mountPage()
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
})
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
})
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
await flushPromises()
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9',
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
expect.objectContaining({ toast: false }),
)
// 2. Validation (back autoritaire) — ne porte que les 4 champs du haut.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9/validate',
expect.objectContaining({ counterpartyType: 'CLIENT', immatriculation: 'AB-123-CD' }),
expect.objectContaining({ toast: false }),
)
// « Enregistrer » ouvre aussi le bon de pesée PDF (RG-5.08).
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen
const NewPage = (await import('../weighing-tickets/new.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
})
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { NewPage },
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
beforeEach(() => {
mockPost.mockReset().mockResolvedValue({ id: 42 })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
})
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
})
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
await flushPromises()
// 1. Création du brouillon (POST) → récupère l'id.
expect(mockPost).toHaveBeenCalledWith(
'/weighing_tickets',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 2. Validation (back autoritaire) sur l'id retourné.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/42/validate',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 3. Ouverture du bon de pesée PDF + retour à la liste.
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -0,0 +1,200 @@
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 ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockReset = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// 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).
vi.stubGlobal('useWeighingTicketsRepository', () => ({
items: ref([
{
id: 9,
number: '86-TP-0001',
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
supplier: null,
otherLabel: null,
displayDate: '2026-06-17T09:12:00+02:00',
netWeight: 7150,
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: vi.fn(),
reset: mockReset,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
const capturedRows = ref<Array<Record<string, unknown>>>([])
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => {
capturedRows.value = props.items as Array<Record<string, unknown>>
return h('div', { 'data-testid': 'datatable' },
(props.items as Array<Record<string, unknown>>).map(it =>
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
h('td', { 'data-cell': 'client' }, it.client as string),
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
]),
),
)
}
},
})
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
// 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() {
const wrapper = mount(WeighingTicketsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
},
},
})
mountedWrappers.push(wrapper)
return wrapper
}
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
beforeEach(() => {
mockPush.mockReset()
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 () => {
mountPage()
await flushPromises()
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()
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
})
it('formate le poids net en kg avec separateur de milliers', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
})
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
})
it('navigue vers la modification au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="9"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
})
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/weighing_tickets/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
})
@@ -0,0 +1,421 @@
<template>
<div>
<!-- En-tête : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('logistique.weighingTickets.form.back')"
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- États de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
<template v-else>
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
sépare chacun des 3 blocs (divide-y). -->
<div class="mt-[48px] flex flex-col divide-y divide-black">
<!-- 4 champs du haut : contrepartie + immatriculation + « Tout
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
padding-top (marge titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
/>
<MalioSelect
v-if="form.counterpartyField.value === 'supplier'"
:model-value="form.supplierIri.value"
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
/>
<MalioSelect
v-else-if="form.counterpartyField.value === 'client'"
:model-value="form.clientIri.value"
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
/>
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
format » se collent au type ; le champ conditionnel les
décale une fois un type choisi. -->
<MalioInputText
:model-value="form.immatriculation.value"
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => form.immatriculation.value = v"
/>
<MalioCheckbox
id="plate-free-format"
:model-value="form.plateFreeFormat.value"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
/>
</div>
</div>
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
<WeighingBlock
class="py-[20px]"
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
/>
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
pour ne pas écarter le bouton). ──────────────────────────── -->
<WeighingBlock
class="pt-[20px]"
block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@request-auto="openAuto('full')"
@request-manual="openManual('full')"
/>
</div>
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
<div class="mt-12 flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:printer-outline"
icon-position="left"
:label="t('logistique.weighingTickets.form.print')"
@click="printTicket"
/>
<MalioButton
variant="primary"
:label="primaryLabel"
:disabled="saving"
@click="submitPrimary"
/>
</div>
</template>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
/>
</template>
</MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
/>
</div>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
if (!can('logistique.weighing_tickets.manage')) {
await navigateTo('/weighing-tickets')
}
const ticketId = route.params.id as string
const form = useWeighingTicketForm()
const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { fetchTicket } = useWeighingTicket()
const { errors, clearErrors, handleApiError } = useFormErrors()
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
const ticketNumber = ref<string>('')
const headerTitle = computed(() =>
ticketNumber.value
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
: t('logistique.weighingTickets.edit.titleFallback'),
)
// Libellé de l'action principale : « Valider » pour un brouillon (finalisation),
// « Enregistrer » pour un ticket déjà validé (mise à jour, ERP-193).
const isValidated = computed(() => form.status.value === 'VALIDATED')
const primaryLabel = computed(() =>
isValidated.value
? t('logistique.weighingTickets.form.save')
: t('logistique.weighingTickets.form.validate'),
)
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
/** Retour vers la liste (flèche d'en-tête). */
function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
])
function onCounterpartyTypeChange(value: string | number | null): void {
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
form.setCounterpartyType(type)
}
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
}))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
(form[target] as Record<string, unknown>)[field as string] = value
}
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
const autoModal = reactive({
open: false,
error: '',
loading: false,
target: 'empty' as 'empty' | 'full',
})
function openAuto(target: 'empty' | 'full'): void {
autoModal.target = target
autoModal.error = ''
autoModal.open = true
}
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
autoModal.error = ''
try {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (e) {
autoModal.error = weighbridge.extractWeighbridgeError(e)
}
finally {
autoModal.loading = false
}
}
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
errors: {} as Record<string, string>,
})
function openManual(target: 'empty' | 'full'): void {
manualModal.target = target
manualModal.weight = null
manualModal.dsd = null
manualModal.errors = {}
manualModal.open = true
}
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> {
if (manualModal.loading) return
manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
}
if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading)
manualModal.open = false
await saveDraft()
}
catch (e) {
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
// (503 pont indispo, réseau) → message générique sous le champ Poids.
const violations = mapViolationsToRecord((e as { response?: { _data?: unknown } })?.response?._data)
manualModal.errors = Object.keys(violations).length > 0
? violations
: { weight: weighbridge.extractWeighbridgeError(e) }
}
finally {
manualModal.loading = false
}
}
// ── Persistance / impression ──────────────────────────────────────────────────
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
async function saveDraft(): Promise<boolean> {
clearErrors()
try {
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
return true
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
}
/**
* Action principale : persiste l'état courant puis finalise/re-valide via
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
*/
async function submitPrimary(): Promise<void> {
if (saving.value) return
saving.value = true
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
router.push('/weighing-tickets')
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
}
finally {
saving.value = false
}
}
/**
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
*/
function printTicket(): void {
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
}
onMounted(async () => {
referentials.load().catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
form.hydrate(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,173 @@
<template>
<div>
<PageHeader>
{{ t('logistique.weighingTickets.title') }}
<template #actions>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('logistique.weighingTickets.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
(regle ABSOLUE n°6). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('logistique.weighingTickets.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('logistique.weighingTickets.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
// Site courant (switcher global) : la liste est cloisonnée par site côté back
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
// contente de recharger quand le site change pour refléter le bon périmètre.
const { currentSite } = useCurrentSite()
useHead({ title: t('logistique.weighingTickets.title') })
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
// back) — spec-front § Acces.
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
const canView = computed(() => can('logistique.weighing_tickets.view'))
const {
items: tickets,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadTickets,
goToPage,
setItemsPerPage,
reset: reloadFromFirstPage,
} = useWeighingTicketsRepository()
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
const rows = computed(() => tickets.value.map(ticket => ({
id: ticket.id,
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
number: ticket.number ?? '',
client: ticket.client?.companyName ?? '',
supplier: ticket.supplier?.companyName ?? '',
otherLabel: ticket.otherLabel ?? '',
displayDate: formatDateFr(ticket.displayDate),
netWeight: formatWeightKg(ticket.netWeight),
status: t(ticket.status === 'VALIDATED'
? 'logistique.weighingTickets.status.validated'
: 'logistique.weighingTickets.status.draft'),
})))
const columns = [
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
]
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/weighing-tickets/${item.id}/edit`)
}
function goToCreate(): void {
router.push('/weighing-tickets/new')
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3/M4).
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'tickets-pesee.xlsx')
}
catch {
toast.error({
title: t('logistique.weighingTickets.toast.error'),
message: t('logistique.weighingTickets.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
// changement.
watch(() => currentSite.value?.id, (id, previousId) => {
if (id !== previousId) {
reloadFromFirstPage()
}
})
onMounted(loadTickets)
</script>
@@ -0,0 +1,381 @@
<template>
<div>
<!-- En-tête : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('logistique.weighingTickets.form.back')"
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
</div>
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
sépare chacun des 3 blocs (divide-y). -->
<div class="mt-[48px] flex flex-col divide-y divide-black">
<!-- 4 champs du haut : contrepartie (type + champ conditionnel),
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
1er bloc : pas de padding-top (marge titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
/>
<MalioSelect
v-if="form.counterpartyField.value === 'supplier'"
:model-value="form.supplierIri.value"
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
/>
<MalioSelect
v-else-if="form.counterpartyField.value === 'client'"
:model-value="form.clientIri.value"
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
/>
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
« Tout format » se collent au type, et le champ conditionnel
les décale une fois un type sélectionné. -->
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
<MalioInputText
:model-value="form.immatriculation.value"
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => form.immatriculation.value = v"
/>
<MalioCheckbox
id="plate-free-format"
:model-value="form.plateFreeFormat.value"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
/>
</div>
</div>
<!-- ── Bloc « Poids à vide » ──────────────────────────────────────── -->
<WeighingBlock
class="py-[20px]"
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
/>
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
pour ne pas écarter le bouton « Valider »). ───────────────────── -->
<WeighingBlock
class="pt-[20px]"
block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@request-auto="openAuto('full')"
@request-manual="openManual('full')"
/>
</div>
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.validate')"
:disabled="validating"
@click="submitValidate"
/>
</div>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
/>
</template>
</MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
/>
</div>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
if (!can('logistique.weighing_tickets.manage')) {
await navigateTo('/weighing-tickets')
}
const form = useWeighingTicketForm()
const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { errors, clearErrors, handleApiError } = useFormErrors()
const validating = ref(false)
/** Retour vers la liste (flèche d'en-tête). */
function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
])
function onCounterpartyTypeChange(value: string | number | null): void {
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
form.setCounterpartyType(type)
}
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
}))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
(form[target] as Record<string, unknown>)[field as string] = value
}
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
const autoModal = reactive({
open: false,
error: '',
loading: false,
target: 'empty' as 'empty' | 'full',
})
function openAuto(target: 'empty' | 'full'): void {
autoModal.target = target
autoModal.error = ''
autoModal.open = true
}
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
autoModal.error = ''
try {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (error) {
autoModal.error = weighbridge.extractWeighbridgeError(error)
}
finally {
autoModal.loading = false
}
}
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
errors: {} as Record<string, string>,
})
function openManual(target: 'empty' | 'full'): void {
manualModal.target = target
manualModal.weight = null
manualModal.dsd = null
manualModal.errors = {}
manualModal.open = true
}
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> {
if (manualModal.loading) return
manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
}
if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading)
manualModal.open = false
await saveDraft()
}
catch (error) {
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
// (503 pont indispo, réseau) → message générique sous le champ Poids.
const violations = mapViolationsToRecord((error as { response?: { _data?: unknown } })?.response?._data)
manualModal.errors = Object.keys(violations).length > 0
? violations
: { weight: weighbridge.extractWeighbridgeError(error) }
}
finally {
manualModal.loading = false
}
}
// ── Persistance ──────────────────────────────────────────────────────────────
interface TicketResponse { id: number }
/**
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
* encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
* mappée inline, ex. format d'immatriculation).
*/
async function saveDraft(): Promise<boolean> {
clearErrors()
try {
if (form.ticketId.value === null) {
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
form.ticketId.value = created.id
}
else {
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
}
return true
}
catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
}
/**
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
*/
async function submitValidate(): Promise<void> {
if (validating.value) return
validating.value = true
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
router.push('/weighing-tickets')
}
catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
}
finally {
validating.value = false
}
}
onMounted(() => {
referentials.load().catch(() => {})
})
</script>
@@ -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('')
})
})
})
@@ -0,0 +1,39 @@
import type { MaskInputOptions } from 'maska'
/**
* Masques de saisie du module « Tickets de pesée » (M5). Partagés entre le
* composant de bloc (`WeighingBlock`) et les modales de pesée (écrans Ajouter /
* Modifier). La validation de format reste autoritaire côté serveur (RG-5.01).
*/
/**
* Masque « chiffres uniquement » (longueur libre) — Poids et DSD. Verrouille la
* saisie sur des entiers.
*/
export const NUMERIC_MASK: MaskInputOptions = {
mask: 'D',
tokens: { D: { pattern: /[0-9]/, multiple: true } },
}
/**
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
*/
export const PLATE_MASK: MaskInputOptions = {
mask: 'AA-###-AA',
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => 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.
*/
export const FREE_PLATE_MASK: MaskInputOptions = {
mask: 'P',
tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } },
preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''),
}
@@ -0,0 +1,32 @@
/**
* 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) : helper partagé inter-modules
// (mutualisé avec les répertoires M1→M4). Re-exporté ici pour les écrans M5.
export { formatDateFr } from '~/shared/utils/date'
/**
* 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() : ''
}