faafd99ef8
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>
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
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,
|
|
}
|
|
}
|