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): Record { 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(null) const clientIri = ref(null) const supplierIri = ref(null) const otherLabel = ref(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(null) const plateFreeFormat = ref(false) // ── Les deux pesées ─────────────────────────────────────────────────────── const empty = reactive(emptyBlock(now)) const full = reactive(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(null) // Cycle de vie courant (DRAFT tant que non validé, ERP-193). const status = ref('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 { 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 { 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 { 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 { 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 { 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, } }