feat : M5 — Tickets de pesée (ERP-188 → ERP-193) (#144)
Auto Tag Develop / tag (push) Successful in 8s
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
+58
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user