9e2206a7d6
En pesée manuelle, le serveur incrémentait automatiquement le DSD et ignorait la saisie de l'opérateur. Désormais l'opérateur saisit le poids ET le DSD (le numéro du pont réellement utilisé), conservés tels quels — plus d'auto-incrément. Le champ « Numéro de pesée » séparé (manualNumber) est supprimé : pour le client c'est la même chose que le DSD. Pas de contrainte d'unicité sur le DSD (doublons autorisés). Colonnes empty_manual_number/full_manual_number droppées.
288 lines
12 KiB
TypeScript
288 lines
12 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 {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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({
|
|
counterpartyType: counterpartyType.value,
|
|
...counterpartyPayload(),
|
|
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,
|
|
}
|
|
}
|