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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user