feat : cycle de vie brouillon/validé du ticket de pesée (ERP-193)
Une pesée (bascule ou manuelle) s'enregistre désormais dès la validation de sa
modale, sans exiger la contrepartie ni l'immatriculation : le ticket naît
« brouillon » (status DRAFT, sans numéro). Le bouton « Valider » finalise quand
les 3 champs du haut (contrepartie + champ associé + immatriculation) ET les 2
pesées sont renseignés : attribution du numéro {siteCode}-TP-{NNNN} et passage
en VALIDATED, puis ouverture du bon de pesée PDF.
Back : counterparty_type/immatriculation/number nullables + colonne status
(migration racine), contraintes strictes déplacées en groupe de validation
finalize, opération PATCH /weighing_tickets/{id}/validate, numéro attribué à la
validation. Front : 4 champs en haut hors blocs, persistance immédiate des
pesées, écrans Ajouter/Modifier refondus, colonne Statut dans la liste, form à
plat pleine largeur. Tests back (lifecycle brouillon/validate) + front à jour.
This commit is contained in:
@@ -703,7 +703,12 @@
|
|||||||
"supplier": "Fournisseur",
|
"supplier": "Fournisseur",
|
||||||
"other": "Autre",
|
"other": "Autre",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"weight": "Poids"
|
"weight": "Poids",
|
||||||
|
"status": "Statut"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "En attente",
|
||||||
|
"validated": "Terminée"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"back": "Retour à la liste",
|
"back": "Retour à la liste",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
<!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
|
||||||
|
<div>
|
||||||
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||||
@@ -23,122 +24,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-4">
|
<!-- Ligne : Date/heure, Poids, DSD. L'immatriculation et « Tout format »
|
||||||
<!-- Ligne 1 : contrepartie (type en col 1 + champ conditionnel en col 2),
|
vivent désormais dans les 4 champs du haut, hors des blocs (ERP-193). -->
|
||||||
rendue par le parent (bloc vide uniquement) via le slot. -->
|
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<div v-if="$slots.counterparty" class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
<!-- Date/heure de la pesée — date du jour + heure courante par défaut
|
||||||
<slot name="counterparty" />
|
(RG-5.07), ré-horodatée à la validation de la pesée. -->
|
||||||
</div>
|
<MalioDateTime
|
||||||
|
:model-value="block.date"
|
||||||
|
:label="t('logistique.weighingTickets.form.date')"
|
||||||
|
:required="true"
|
||||||
|
:editable="true"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors.date"
|
||||||
|
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Ligne 2 : Date, Poids, DSD, Immatriculation. -->
|
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
(rempli par la pesée, jamais saisi à la main — RG-5.07). -->
|
||||||
<!-- Date/heure de la pesée — date du jour + heure courante par défaut
|
<MalioInputText
|
||||||
(RG-5.07). MalioDateTime : on enregistre l'instant réel de la pesée
|
:model-value="weightDisplay"
|
||||||
(jamais 00:00:00), le back stocke un TIMESTAMP. -->
|
:mask="NUMERIC_MASK"
|
||||||
<MalioDateTime
|
:label="t('logistique.weighingTickets.form.weight')"
|
||||||
:model-value="block.date"
|
:required="true"
|
||||||
:label="t('logistique.weighingTickets.form.date')"
|
:disabled="true"
|
||||||
:required="true"
|
:error="errors.weight"
|
||||||
:editable="true"
|
/>
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors.date"
|
|
||||||
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
|
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
|
||||||
(rempli par la pesée, jamais saisi à la main — RG-5.07). Unité Kg
|
(rempli par la pesée — RG-5.04 / RG-5.07). -->
|
||||||
dans le label. -->
|
<MalioInputText
|
||||||
<MalioInputText
|
:model-value="dsdDisplay"
|
||||||
:model-value="weightDisplay"
|
:mask="NUMERIC_MASK"
|
||||||
:mask="NUMERIC_MASK"
|
:label="t('logistique.weighingTickets.form.dsd')"
|
||||||
:label="t('logistique.weighingTickets.form.weight')"
|
:required="true"
|
||||||
:required="true"
|
:disabled="true"
|
||||||
:disabled="true"
|
:error="errors.dsd"
|
||||||
:error="errors.weight"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
|
|
||||||
(rempli par la pesée — RG-5.04 / RG-5.07). -->
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="dsdDisplay"
|
|
||||||
:mask="NUMERIC_MASK"
|
|
||||||
:label="t('logistique.weighingTickets.form.dsd')"
|
|
||||||
:required="true"
|
|
||||||
:disabled="true"
|
|
||||||
:error="errors.dsd"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout format »,
|
|
||||||
masque ÉLARGI (lettres/chiffres/espace/tiret, MAJ) pour les plaques
|
|
||||||
anciennes/étrangères, mais sans laisser passer n'importe quoi.
|
|
||||||
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
|
|
||||||
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
|
|
||||||
<MalioInputText
|
|
||||||
:model-value="immatriculation"
|
|
||||||
:mask="plateFreeFormat ? FREE_PLATE_MASK : PLATE_MASK"
|
|
||||||
:label="t('logistique.weighingTickets.form.immatriculation')"
|
|
||||||
:required="true"
|
|
||||||
:disabled="disabled"
|
|
||||||
:error="errors.immatriculation"
|
|
||||||
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ligne 3 : « Tout format » (désactive le masque plaque). Partagé entre
|
|
||||||
blocs (RG-5.01). Sur sa propre ligne. -->
|
|
||||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
:id="`${blockId}-plate-free-format`"
|
|
||||||
:model-value="plateFreeFormat"
|
|
||||||
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
|
||||||
group-class="self-center"
|
|
||||||
:disabled="disabled"
|
|
||||||
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
|
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
|
||||||
* Champs Date / Poids / DSD / Immatriculation / « Tout format » + boutons de pesée.
|
* Champs Date/heure / Poids / DSD + boutons de pesée (bascule / manuelle). Depuis
|
||||||
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) :
|
* ERP-193, la contrepartie, l'immatriculation et « Tout format » sont remontés dans
|
||||||
* portés par le form parent et remontés en `update:*`. Le slot `counterparty`
|
* les 4 champs du haut de page (hors blocs). Masque numérique factorisé dans
|
||||||
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03).
|
* `utils/weighingMasks`.
|
||||||
* Masques de saisie factorisés dans `utils/weighingMasks`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||||
blockId: string
|
blockId: string
|
||||||
title: string
|
title: string
|
||||||
block: WeighingBlockState
|
block: WeighingBlockState
|
||||||
/** Immatriculation partagée (RG-5.01) — portée par le form parent. */
|
|
||||||
immatriculation: string | null
|
|
||||||
/** « Tout format » partagé (RG-5.01) — porté par le form parent. */
|
|
||||||
plateFreeFormat: boolean
|
|
||||||
/** Erreurs 422 par champ (propertyPath → message). */
|
/** Erreurs 422 par champ (propertyPath → message). */
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
}>(), {
|
||||||
|
errors: () => ({}),
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
||||||
'update:immatriculation': [value: string | null]
|
|
||||||
'update:plateFreeFormat': [value: boolean]
|
|
||||||
'request-auto': []
|
'request-auto': []
|
||||||
'request-manual': []
|
'request-manual': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const errors = computed(() => props.errors ?? {})
|
|
||||||
|
|
||||||
// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
|
// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
|
||||||
// tant que la pesée n'a pas rempli la valeur).
|
// tant que la pesée n'a pas rempli la valeur).
|
||||||
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
|
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ describe('useWeighingTicketForm', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Omission des requis vides (compact) ──────────────────────────────────
|
// ── Omission des requis vides (compact) ──────────────────────────────────
|
||||||
it('buildCreatePayload omet les clés null (requis vides absents, pas envoyés à null)', () => {
|
it('buildDraftPayload : brouillon vierge → pas de champ requis ni de bloc non pesé', () => {
|
||||||
const form = useWeighingTicketForm()
|
const form = useWeighingTicketForm()
|
||||||
// Formulaire vierge : counterpartyType / immatriculation non remplis.
|
// Formulaire vierge : counterpartyType / immatriculation non remplis, aucune pesée.
|
||||||
const payload = form.buildCreatePayload()
|
const payload = form.buildDraftPayload()
|
||||||
// Absents (et non null) → le back applique NotBlank (message métier) plutôt
|
// Absents (et non null) → le back laisse jouer les contraintes du groupe finalize.
|
||||||
// qu'une erreur de type opaque (« doit être de type string »).
|
|
||||||
expect(payload).not.toHaveProperty('counterpartyType')
|
expect(payload).not.toHaveProperty('counterpartyType')
|
||||||
expect(payload).not.toHaveProperty('immatriculation')
|
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('emptyWeight')
|
||||||
// Les non-null restent : date/heure courante + booléen Tout format.
|
expect(payload).not.toHaveProperty('emptyDate')
|
||||||
expect(payload.emptyDate).toBe('2026-06-22T08:30:00')
|
// Seul le booléen « Tout format » reste.
|
||||||
expect(payload.plateFreeFormat).toBe(false)
|
expect(payload.plateFreeFormat).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('useWeighingTicketForm', () => {
|
|||||||
expect(form.supplierIri.value).toBeNull()
|
expect(form.supplierIri.value).toBeNull()
|
||||||
expect(form.otherLabel.value).toBeNull()
|
expect(form.otherLabel.value).toBeNull()
|
||||||
|
|
||||||
const payload = form.buildCreatePayload()
|
const payload = form.buildDraftPayload()
|
||||||
expect(payload.counterpartyType).toBe('CLIENT')
|
expect(payload.counterpartyType).toBe('CLIENT')
|
||||||
expect(payload.client).toBe('/api/clients/629')
|
expect(payload.client).toBe('/api/clients/629')
|
||||||
expect(payload).not.toHaveProperty('supplier')
|
expect(payload).not.toHaveProperty('supplier')
|
||||||
@@ -68,7 +68,7 @@ describe('useWeighingTicketForm', () => {
|
|||||||
|
|
||||||
expect(form.counterpartyField.value).toBe('supplier')
|
expect(form.counterpartyField.value).toBe('supplier')
|
||||||
expect(form.clientIri.value).toBeNull()
|
expect(form.clientIri.value).toBeNull()
|
||||||
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
|
expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('AUTRE : ne conserve que le libellé libre', () => {
|
it('AUTRE : ne conserve que le libellé libre', () => {
|
||||||
@@ -79,7 +79,7 @@ describe('useWeighingTicketForm', () => {
|
|||||||
|
|
||||||
expect(form.counterpartyField.value).toBe('other')
|
expect(form.counterpartyField.value).toBe('other')
|
||||||
expect(form.clientIri.value).toBeNull()
|
expect(form.clientIri.value).toBeNull()
|
||||||
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
|
expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
||||||
@@ -88,11 +88,11 @@ describe('useWeighingTicketForm', () => {
|
|||||||
form.immatriculation.value = 'AB-123-CD'
|
form.immatriculation.value = 'AB-123-CD'
|
||||||
form.plateFreeFormat.value = true
|
form.plateFreeFormat.value = true
|
||||||
|
|
||||||
// Les 2 payloads (création + finalisation) reflètent la même valeur.
|
// Les 2 payloads (brouillon + validation) reflètent la même valeur.
|
||||||
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
|
expect(form.buildDraftPayload().immatriculation).toBe('AB-123-CD')
|
||||||
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
|
expect(form.buildDraftPayload().plateFreeFormat).toBe(true)
|
||||||
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
|
expect(form.buildValidatePayload().immatriculation).toBe('AB-123-CD')
|
||||||
expect(form.buildFullPayload().plateFreeFormat).toBe(true)
|
expect(form.buildValidatePayload().plateFreeFormat).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Application d'une lecture de pesée ────────────────────────────────────
|
// ── Application d'une lecture de pesée ────────────────────────────────────
|
||||||
@@ -113,23 +113,28 @@ describe('useWeighingTicketForm', () => {
|
|||||||
expect(form.full.manualNumber).toBe('PAP-555')
|
expect(form.full.manualNumber).toBe('PAP-555')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
|
it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
|
||||||
const form = useWeighingTicketForm()
|
const form = useWeighingTicketForm()
|
||||||
form.setCounterpartyType('CLIENT')
|
form.setCounterpartyType('CLIENT')
|
||||||
form.clientIri.value = '/api/clients/1'
|
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.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
||||||
|
|
||||||
const create = form.buildCreatePayload()
|
// Le brouillon porte LES DEUX pesées effectuées.
|
||||||
expect(create.emptyWeight).toBe(7150)
|
const draft = form.buildDraftPayload()
|
||||||
expect(create.emptyDsd).toBe(1)
|
expect(draft.emptyWeight).toBe(7150)
|
||||||
expect(create.emptyMode).toBe('AUTO')
|
expect(draft.emptyMode).toBe('AUTO')
|
||||||
expect(create).not.toHaveProperty('fullWeight')
|
expect(draft.fullWeight).toBe(14300)
|
||||||
|
expect(draft.fullMode).toBe('AUTO')
|
||||||
|
|
||||||
const full = form.buildFullPayload()
|
// La validation ne porte que les 4 champs du haut (pesées déjà persistées).
|
||||||
expect(full.fullWeight).toBe(14300)
|
const validate = form.buildValidatePayload()
|
||||||
expect(full.fullDsd).toBe(2)
|
expect(validate.counterpartyType).toBe('CLIENT')
|
||||||
expect(full.fullMode).toBe('AUTO')
|
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) ─────────────────────────
|
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
|
||||||
@@ -174,10 +179,11 @@ describe('useWeighingTicketForm', () => {
|
|||||||
expect(form.empty.weight).toBeNull()
|
expect(form.empty.weight).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('buildUpdatePayload fusionne contrepartie + véhicule + les 2 pesées', () => {
|
it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
|
||||||
const form = useWeighingTicketForm()
|
const form = useWeighingTicketForm()
|
||||||
form.hydrate({
|
form.hydrate({
|
||||||
id: 9,
|
id: 9,
|
||||||
|
status: 'VALIDATED',
|
||||||
counterpartyType: 'CLIENT',
|
counterpartyType: 'CLIENT',
|
||||||
client: { '@id': '/api/clients/629' },
|
client: { '@id': '/api/clients/629' },
|
||||||
immatriculation: 'AB-123-CD',
|
immatriculation: 'AB-123-CD',
|
||||||
@@ -185,7 +191,9 @@ describe('useWeighingTicketForm', () => {
|
|||||||
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = form.buildUpdatePayload()
|
expect(form.status.value).toBe('VALIDATED')
|
||||||
|
|
||||||
|
const payload = form.buildDraftPayload()
|
||||||
expect(payload.counterpartyType).toBe('CLIENT')
|
expect(payload.counterpartyType).toBe('CLIENT')
|
||||||
expect(payload.client).toBe('/api/clients/629')
|
expect(payload.client).toBe('/api/clients/629')
|
||||||
expect(payload.emptyWeight).toBe(7150)
|
expect(payload.emptyWeight).toBe(7150)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm'
|
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
|
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
|
||||||
@@ -8,11 +8,13 @@ import type { CounterpartyType } from '~/modules/logistique/composables/useWeigh
|
|||||||
*/
|
*/
|
||||||
export interface WeighingTicketDetail {
|
export interface WeighingTicketDetail {
|
||||||
id: number
|
id: number
|
||||||
/** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */
|
/** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
|
||||||
number: string
|
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 rattaché (embarqué) — immuable (RG-5.09). */
|
||||||
site?: { id: number, name: string, code: string } | null
|
site?: { id: number, name: string, code: string } | null
|
||||||
counterpartyType: CounterpartyType
|
counterpartyType?: CounterpartyType | null
|
||||||
client?: { '@id': string, companyName: string } | null
|
client?: { '@id': string, companyName: string } | null
|
||||||
supplier?: { '@id': string, companyName: string } | null
|
supplier?: { '@id': string, companyName: string } | null
|
||||||
otherLabel?: string | null
|
otherLabel?: string | null
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighb
|
|||||||
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||||
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||||
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||||
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
|
* - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
|
||||||
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
|
* blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
|
||||||
* les 2 blocs bindent la même ref.
|
* - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
|
||||||
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
|
* courant (pesée enregistrée dès la validation de sa modale, même sans
|
||||||
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
|
* contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
|
||||||
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
|
* 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
|
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||||
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||||
@@ -39,10 +40,14 @@ export interface WeighingBlockState {
|
|||||||
manualNumber: string | null
|
manualNumber: string | 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). */
|
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
|
||||||
export interface WeighingTicketHydration {
|
export interface WeighingTicketHydration {
|
||||||
id: number
|
id: number
|
||||||
counterpartyType: CounterpartyType
|
status?: WeighingTicketStatus
|
||||||
|
counterpartyType?: CounterpartyType | null
|
||||||
client?: { '@id': string } | null
|
client?: { '@id': string } | null
|
||||||
supplier?: { '@id': string } | null
|
supplier?: { '@id': string } | null
|
||||||
otherLabel?: string | null
|
otherLabel?: string | null
|
||||||
@@ -124,9 +129,13 @@ export function useWeighingTicketForm() {
|
|||||||
const empty = reactive<WeighingBlockState>(emptyBlock(now))
|
const empty = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
const full = reactive<WeighingBlockState>(emptyBlock(now))
|
const full = reactive<WeighingBlockState>(emptyBlock(now))
|
||||||
|
|
||||||
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
|
// 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)
|
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
|
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||||
* pour afficher conditionnellement le bon champ (RG-5.03).
|
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||||
@@ -183,22 +192,35 @@ export function useWeighingTicketForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
|
* Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
|
||||||
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
|
* n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
|
||||||
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
|
* date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
|
||||||
* pour que `useFormErrors` mappe les 422 inline.
|
|
||||||
*/
|
*/
|
||||||
function buildCreatePayload(): Record<string, unknown> {
|
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,
|
||||||
|
[`${prefix}ManualNumber`]: block.manualNumber || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
return compact({
|
||||||
counterpartyType: counterpartyType.value,
|
counterpartyType: counterpartyType.value,
|
||||||
...counterpartyPayload(),
|
...counterpartyPayload(),
|
||||||
immatriculation: immatriculation.value || null,
|
immatriculation: immatriculation.value || null,
|
||||||
plateFreeFormat: plateFreeFormat.value,
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
emptyDate: empty.date || null,
|
...blockPayload('empty', empty),
|
||||||
emptyWeight: empty.weight,
|
...blockPayload('full', full),
|
||||||
emptyDsd: empty.dsd,
|
|
||||||
emptyMode: empty.mode,
|
|
||||||
emptyManualNumber: empty.manualNumber || null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +233,7 @@ export function useWeighingTicketForm() {
|
|||||||
*/
|
*/
|
||||||
function hydrate(detail: WeighingTicketHydration): void {
|
function hydrate(detail: WeighingTicketHydration): void {
|
||||||
ticketId.value = detail.id
|
ticketId.value = detail.id
|
||||||
|
status.value = detail.status ?? 'DRAFT'
|
||||||
counterpartyType.value = detail.counterpartyType ?? null
|
counterpartyType.value = detail.counterpartyType ?? null
|
||||||
clientIri.value = detail.client?.['@id'] ?? null
|
clientIri.value = detail.client?.['@id'] ?? null
|
||||||
supplierIri.value = detail.supplier?.['@id'] ?? null
|
supplierIri.value = detail.supplier?.['@id'] ?? null
|
||||||
@@ -232,30 +255,18 @@ export function useWeighingTicketForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les
|
* Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
|
||||||
* champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le
|
* 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
|
||||||
* site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net
|
* sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
|
||||||
* est recalculé serveur (RG-5.05).
|
* 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 buildUpdatePayload(): Record<string, unknown> {
|
function buildValidatePayload(): Record<string, unknown> {
|
||||||
return { ...buildCreatePayload(), ...buildFullPayload() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
|
|
||||||
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
|
|
||||||
* les 2 blocs → on le repousse aussi (valeur partagée, RG-5.01). Le net est
|
|
||||||
* recalculé serveur (RG-5.05).
|
|
||||||
*/
|
|
||||||
function buildFullPayload(): Record<string, unknown> {
|
|
||||||
return compact({
|
return compact({
|
||||||
|
counterpartyType: counterpartyType.value,
|
||||||
|
...counterpartyPayload(),
|
||||||
immatriculation: immatriculation.value || null,
|
immatriculation: immatriculation.value || null,
|
||||||
plateFreeFormat: plateFreeFormat.value,
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
fullDate: full.date || null,
|
|
||||||
fullWeight: full.weight,
|
|
||||||
fullDsd: full.dsd,
|
|
||||||
fullMode: full.mode,
|
|
||||||
fullManualNumber: full.manualNumber || null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,9 +288,9 @@ export function useWeighingTicketForm() {
|
|||||||
missingWeighingFields,
|
missingWeighingFields,
|
||||||
// workflow
|
// workflow
|
||||||
ticketId,
|
ticketId,
|
||||||
|
status,
|
||||||
hydrate,
|
hydrate,
|
||||||
buildCreatePayload,
|
buildDraftPayload,
|
||||||
buildFullPayload,
|
buildValidatePayload,
|
||||||
buildUpdatePayload,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
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
|
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||||
@@ -25,8 +26,10 @@ export interface WeighingTicketParty {
|
|||||||
*/
|
*/
|
||||||
export interface WeighingTicket {
|
export interface WeighingTicket {
|
||||||
id: number
|
id: number
|
||||||
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
|
/** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
|
||||||
number: string
|
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. */
|
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||||
client: WeighingTicketParty | null
|
client: WeighingTicketParty | null
|
||||||
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ const InputStub = defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide).
|
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
|
||||||
|
// dans les 4 champs du haut, hors bloc (ERP-193).
|
||||||
const BlockStub = defineComponent({
|
const BlockStub = defineComponent({
|
||||||
setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) },
|
setup() { return () => h('div', { 'data-testid': 'block' }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const ModalStub = defineComponent({
|
const ModalStub = defineComponent({
|
||||||
@@ -82,6 +83,7 @@ async function mountPage() {
|
|||||||
|
|
||||||
const DETAIL = {
|
const DETAIL = {
|
||||||
id: 9,
|
id: 9,
|
||||||
|
status: 'VALIDATED',
|
||||||
number: '86-TP-0001',
|
number: '86-TP-0001',
|
||||||
site: { id: 1, name: 'Chatellerault', code: '86' },
|
site: { id: 1, name: 'Chatellerault', code: '86' },
|
||||||
counterpartyType: 'CLIENT',
|
counterpartyType: 'CLIENT',
|
||||||
@@ -105,11 +107,11 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
|
|||||||
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « Valider »', async () => {
|
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
||||||
const wrapper = await mountPage()
|
const wrapper = await mountPage()
|
||||||
|
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
|
||||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
|
||||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
|
||||||
// « Valider » est le bouton de l'écran d'AJOUT — absent en modification (RG-5.08).
|
|
||||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,15 +121,24 @@ describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit
|
|||||||
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('« Enregistrer » PATCH le ticket puis revient à la liste', async () => {
|
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
|
||||||
const wrapper = await mountPage()
|
const wrapper = await mountPage()
|
||||||
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
|
||||||
expect(mockPatch).toHaveBeenCalledWith(
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
'/weighing_tickets/9',
|
'/weighing_tickets/9',
|
||||||
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
||||||
expect.objectContaining({ toast: false }),
|
expect.objectContaining({ toast: false }),
|
||||||
)
|
)
|
||||||
|
// 2. Validation (back autoritaire) — ne porte que les 4 champs du haut.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/9/validate',
|
||||||
|
expect.objectContaining({ counterpartyType: 'CLIENT', immatriculation: 'AB-123-CD' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// « Enregistrer » ouvre aussi le bon de pesée PDF (RG-5.08).
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||||
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOpen = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||||
|
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||||
|
}))
|
||||||
|
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||||
|
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||||
|
vi.stubGlobal('navigateTo', vi.fn())
|
||||||
|
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||||
|
globalThis.open = mockOpen
|
||||||
|
|
||||||
|
const NewPage = (await import('../weighing-tickets/new.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
|
||||||
|
})
|
||||||
|
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
|
||||||
|
const ModalStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioDateTime: InputStub,
|
||||||
|
MalioCheckbox: InputStub,
|
||||||
|
MalioModal: ModalStub,
|
||||||
|
WeighingBlock: BlockStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { NewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset().mockResolvedValue({ id: 42 })
|
||||||
|
mockPatch.mockReset().mockResolvedValue({})
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockOpen.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// 1. Création du brouillon (POST) → récupère l'id.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 2. Validation (back autoritaire) sur l'id retourné.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/42/validate',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// 3. Ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -18,23 +18,14 @@
|
|||||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Numéro + site : immuables (RG-5.09), rappelés dans le titre de l'écran. -->
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
<div class="mt-[48px] flex flex-col gap-8">
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──── -->
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
<WeighingBlock
|
<!-- ── 4 champs du haut : contrepartie + immatriculation + « Tout
|
||||||
block-id="empty"
|
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
|
||||||
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
padding-top (marge titre→form = mt-[48px] standard). ───────── -->
|
||||||
:block="form.empty"
|
<div class="pb-[20px]">
|
||||||
:immatriculation="form.immatriculation.value"
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:plate-free-format="form.plateFreeFormat.value"
|
|
||||||
:errors="emptyBlockErrors"
|
|
||||||
@update:block="(field, value) => updateBlock('empty', field, value)"
|
|
||||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
|
||||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
|
||||||
@request-auto="openAuto('empty')"
|
|
||||||
@request-manual="openManual('empty')"
|
|
||||||
>
|
|
||||||
<template #counterparty>
|
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="form.counterpartyType.value"
|
:model-value="form.counterpartyType.value"
|
||||||
:options="counterpartyOptions"
|
:options="counterpartyOptions"
|
||||||
@@ -72,28 +63,56 @@
|
|||||||
:error="errors.otherLabel"
|
:error="errors.otherLabel"
|
||||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</WeighingBlock>
|
|
||||||
|
|
||||||
<!-- Bloc « Poids à plein » : le bouton « Enregistrer » du bloc vide
|
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
|
||||||
DISPARAÎT en modification (RG-5.08) — on enregistre via le bas. -->
|
format » se collent au type ; le champ conditionnel les
|
||||||
|
décale une fois un type choisi. -->
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="form.immatriculation.value"
|
||||||
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
|
||||||
<WeighingBlock
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton). ──────────────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
block-id="full"
|
block-id="full"
|
||||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
:block="form.full"
|
:block="form.full"
|
||||||
:immatriculation="form.immatriculation.value"
|
|
||||||
:plate-free-format="form.plateFreeFormat.value"
|
|
||||||
:errors="fullBlockErrors"
|
:errors="fullBlockErrors"
|
||||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
|
||||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
|
||||||
@request-auto="openAuto('full')"
|
@request-auto="openAuto('full')"
|
||||||
@request-manual="openManual('full')"
|
@request-manual="openManual('full')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bas d'écran : « Enregistrer » (remplace « Valider », RG-5.08) +
|
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
|
||||||
« Imprimer » (absent à l'ajout, RG-5.08). -->
|
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
|
||||||
<div class="mt-12 flex justify-center gap-6">
|
<div class="mt-12 flex justify-center gap-6">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -104,16 +123,14 @@
|
|||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('logistique.weighingTickets.form.save')"
|
:label="primaryLabel"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
@click="submitSave"
|
@click="submitPrimary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
<!-- La question est portée par le titre ; pas de texte de corps. Bouton
|
|
||||||
« Valider » seul, centré (l'annulation se fait via la croix). -->
|
|
||||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
@@ -130,9 +147,6 @@
|
|||||||
</MalioModal>
|
</MalioModal>
|
||||||
|
|
||||||
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
<!-- Marges : titre UPPERCASE à 24px du haut (pt-6), 28px horizontaux (mx-7 header
|
|
||||||
/ px-7 body+footer), bordure à 12px sous le titre (pb-3) et insérée (mx-7,
|
|
||||||
ne touche pas les bords), formulaire à 36px sous la bordure (pt-9). -->
|
|
||||||
<MalioModal
|
<MalioModal
|
||||||
v-model="manualModal.open"
|
v-model="manualModal.open"
|
||||||
modal-class="max-w-md"
|
modal-class="max-w-md"
|
||||||
@@ -144,7 +158,6 @@
|
|||||||
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- Poids : champ texte verrouillé sur les chiffres (comme le formulaire). -->
|
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="manualModal.weight"
|
v-model="manualModal.weight"
|
||||||
:mask="NUMERIC_MASK"
|
:mask="NUMERIC_MASK"
|
||||||
@@ -177,7 +190,7 @@ import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logist
|
|||||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -196,30 +209,13 @@ const form = useWeighingTicketForm()
|
|||||||
const weighbridge = useWeighbridge()
|
const weighbridge = useWeighbridge()
|
||||||
const referentials = useWeighingTicketReferentials()
|
const referentials = useWeighingTicketReferentials()
|
||||||
const { fetchTicket } = useWeighingTicket()
|
const { fetchTicket } = useWeighingTicket()
|
||||||
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
/**
|
|
||||||
* Marque Poids/DSD manquants d'un bloc (RG-5.07). `emptyWeight` est validé côté
|
|
||||||
* back (NotBlank → renvoyé avec les autres violations) ; `fullWeight` n'a pas
|
|
||||||
* d'équivalent back (workflow 2 temps) et reste donc front-only. Le DSD est
|
|
||||||
* alloué serveur → simple repère front en miroir du poids. Retourne false si une
|
|
||||||
* pesée manque.
|
|
||||||
*/
|
|
||||||
function validateWeighing(which: 'empty' | 'full'): boolean {
|
|
||||||
const missing = form.missingWeighingFields(which)
|
|
||||||
for (const path of missing) {
|
|
||||||
setError(path, path.endsWith('Weight')
|
|
||||||
? t('logistique.weighingTickets.form.weightRequired')
|
|
||||||
: t('logistique.weighingTickets.form.dsdRequired'))
|
|
||||||
}
|
|
||||||
return missing.length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(false)
|
const error = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
// Numéro immuable (RG-5.09), rappelé dans le titre de l'écran.
|
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
|
||||||
const ticketNumber = ref<string>('')
|
const ticketNumber = ref<string>('')
|
||||||
|
|
||||||
const headerTitle = computed(() =>
|
const headerTitle = computed(() =>
|
||||||
@@ -228,6 +224,15 @@ const headerTitle = computed(() =>
|
|||||||
: t('logistique.weighingTickets.edit.titleFallback'),
|
: t('logistique.weighingTickets.edit.titleFallback'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Libellé de l'action principale : « Valider » pour un brouillon (finalisation),
|
||||||
|
// « Enregistrer » pour un ticket déjà validé (mise à jour, ERP-193).
|
||||||
|
const isValidated = computed(() => form.status.value === 'VALIDATED')
|
||||||
|
const primaryLabel = computed(() =>
|
||||||
|
isValidated.value
|
||||||
|
? t('logistique.weighingTickets.form.save')
|
||||||
|
: t('logistique.weighingTickets.form.validate'),
|
||||||
|
)
|
||||||
|
|
||||||
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
|
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
|
||||||
|
|
||||||
/** Retour vers la liste (flèche d'en-tête). */
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
@@ -235,7 +240,7 @@ function goBack(): void {
|
|||||||
router.push('/weighing-tickets')
|
router.push('/weighing-tickets')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
const counterpartyOptions = computed<RefOption[]>(() => [
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
@@ -252,11 +257,7 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
|||||||
date: errors.emptyDate,
|
date: errors.emptyDate,
|
||||||
weight: errors.emptyWeight,
|
weight: errors.emptyWeight,
|
||||||
dsd: errors.emptyDsd,
|
dsd: errors.emptyDsd,
|
||||||
immatriculation: errors.immatriculation,
|
|
||||||
}))
|
}))
|
||||||
// Immatriculation volontairement ABSENTE ici : partagée entre les 2 blocs
|
|
||||||
// (RG-5.01) mais affichée/validée sur le bloc « Poids à vide » uniquement — pas
|
|
||||||
// de doublon d'erreur sur le bloc « Poids à plein ».
|
|
||||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
date: errors.fullDate,
|
date: errors.fullDate,
|
||||||
weight: errors.fullWeight,
|
weight: errors.fullWeight,
|
||||||
@@ -282,6 +283,7 @@ function openAuto(target: 'empty' | 'full'): void {
|
|||||||
autoModal.open = true
|
autoModal.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
async function confirmAuto(): Promise<void> {
|
async function confirmAuto(): Promise<void> {
|
||||||
if (autoModal.loading) return
|
if (autoModal.loading) return
|
||||||
autoModal.loading = true
|
autoModal.loading = true
|
||||||
@@ -290,6 +292,7 @@ async function confirmAuto(): Promise<void> {
|
|||||||
const reading = await weighbridge.triggerAuto()
|
const reading = await weighbridge.triggerAuto()
|
||||||
form.applyReading(form[autoModal.target], reading)
|
form.applyReading(form[autoModal.target], reading)
|
||||||
autoModal.open = false
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
||||||
@@ -317,6 +320,7 @@ function openManual(target: 'empty' | 'full'): void {
|
|||||||
manualModal.open = true
|
manualModal.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle, remplit le bloc puis enregistre le brouillon. */
|
||||||
async function confirmManual(): Promise<void> {
|
async function confirmManual(): Promise<void> {
|
||||||
if (manualModal.loading) return
|
if (manualModal.loading) return
|
||||||
manualModal.errors = {}
|
manualModal.errors = {}
|
||||||
@@ -336,6 +340,7 @@ async function confirmManual(): Promise<void> {
|
|||||||
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||||
form.applyReading(form[manualModal.target], reading)
|
form.applyReading(form[manualModal.target], reading)
|
||||||
manualModal.open = false
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) }
|
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) }
|
||||||
@@ -345,18 +350,34 @@ async function confirmManual(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Soumission / impression ───────────────────────────────────────────────────
|
// ── Persistance / impression ──────────────────────────────────────────────────
|
||||||
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
|
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
|
||||||
async function submitSave(): Promise<void> {
|
async function saveDraft(): Promise<boolean> {
|
||||||
if (saving.value) return
|
|
||||||
clearErrors()
|
clearErrors()
|
||||||
// Vide : marqué seulement (le back garde emptyWeight et renvoie tout d'un coup).
|
try {
|
||||||
// Plein : bloquant côté front (pas de règle back, workflow 2 temps).
|
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
|
||||||
validateWeighing('empty')
|
return true
|
||||||
if (!validateWeighing('full')) return
|
}
|
||||||
|
catch (e) {
|
||||||
|
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action principale : persiste l'état courant puis finalise/re-valide via
|
||||||
|
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
|
||||||
|
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
|
||||||
|
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
|
||||||
|
*/
|
||||||
|
async function submitPrimary(): Promise<void> {
|
||||||
|
if (saving.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false })
|
if (!(await saveDraft())) return
|
||||||
|
|
||||||
|
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
|
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||||
router.push('/weighing-tickets')
|
router.push('/weighing-tickets')
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -376,11 +397,10 @@ function printTicket(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Référentiels (selects contrepartie) en parallèle, non bloquants.
|
|
||||||
referentials.load().catch(() => {})
|
referentials.load().catch(() => {})
|
||||||
try {
|
try {
|
||||||
const detail = await fetchTicket(ticketId)
|
const detail = await fetchTicket(ticketId)
|
||||||
ticketNumber.value = detail.number
|
ticketNumber.value = detail.number ?? ''
|
||||||
form.hydrate(detail)
|
form.hydrate(detail)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -84,12 +84,16 @@ const {
|
|||||||
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||||
const rows = computed(() => tickets.value.map(ticket => ({
|
const rows = computed(() => tickets.value.map(ticket => ({
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
number: ticket.number,
|
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
|
||||||
|
number: ticket.number ?? '',
|
||||||
client: ticket.client?.companyName ?? '',
|
client: ticket.client?.companyName ?? '',
|
||||||
supplier: ticket.supplier?.companyName ?? '',
|
supplier: ticket.supplier?.companyName ?? '',
|
||||||
otherLabel: ticket.otherLabel ?? '',
|
otherLabel: ticket.otherLabel ?? '',
|
||||||
displayDate: formatDateFr(ticket.displayDate),
|
displayDate: formatDateFr(ticket.displayDate),
|
||||||
netWeight: formatWeightKg(ticket.netWeight),
|
netWeight: formatWeightKg(ticket.netWeight),
|
||||||
|
status: t(ticket.status === 'VALIDATED'
|
||||||
|
? 'logistique.weighingTickets.status.validated'
|
||||||
|
: 'logistique.weighingTickets.status.draft'),
|
||||||
})))
|
})))
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -99,6 +103,7 @@ const columns = [
|
|||||||
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||||
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||||
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||||
|
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||||
|
|||||||
@@ -13,30 +13,19 @@
|
|||||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-[48px] flex flex-col gap-8">
|
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||||
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──────── -->
|
sépare chacun des 3 blocs (divide-y). -->
|
||||||
<WeighingBlock
|
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||||
block-id="empty"
|
<!-- ── 4 champs du haut : contrepartie (type + champ conditionnel),
|
||||||
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
|
||||||
:block="form.empty"
|
1er bloc : pas de padding-top (marge titre→form = mt-[48px] standard). ── -->
|
||||||
:immatriculation="form.immatriculation.value"
|
<div class="pb-[20px]">
|
||||||
:plate-free-format="form.plateFreeFormat.value"
|
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:errors="emptyBlockErrors"
|
|
||||||
:disabled="emptyLocked"
|
|
||||||
@update:block="(field, value) => updateBlock('empty', field, value)"
|
|
||||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
|
||||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
|
||||||
@request-auto="openAuto('empty')"
|
|
||||||
@request-manual="openManual('empty')"
|
|
||||||
>
|
|
||||||
<!-- Contrepartie : sélecteur + champ conditionnel (RG-5.03). -->
|
|
||||||
<template #counterparty>
|
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="form.counterpartyType.value"
|
:model-value="form.counterpartyType.value"
|
||||||
:options="counterpartyOptions"
|
:options="counterpartyOptions"
|
||||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:disabled="emptyLocked"
|
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:error="errors.counterpartyType"
|
:error="errors.counterpartyType"
|
||||||
@update:model-value="onCounterpartyTypeChange"
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
@@ -47,7 +36,6 @@
|
|||||||
:options="referentials.suppliers.value"
|
:options="referentials.suppliers.value"
|
||||||
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:disabled="emptyLocked"
|
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:error="errors.supplier"
|
:error="errors.supplier"
|
||||||
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
@@ -58,7 +46,6 @@
|
|||||||
:options="referentials.clients.value"
|
:options="referentials.clients.value"
|
||||||
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:disabled="emptyLocked"
|
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
:error="errors.client"
|
:error="errors.client"
|
||||||
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
@@ -68,60 +55,76 @@
|
|||||||
:model-value="form.otherLabel.value"
|
:model-value="form.otherLabel.value"
|
||||||
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:disabled="emptyLocked"
|
|
||||||
:error="errors.otherLabel"
|
:error="errors.otherLabel"
|
||||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</WeighingBlock>
|
|
||||||
|
|
||||||
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une
|
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
|
||||||
fois le ticket créé — RG-5.08). -->
|
« Tout format » se collent au type, et le champ conditionnel
|
||||||
<div v-if="form.ticketId.value === null" class="flex justify-center">
|
les décale une fois un type sélectionné. -->
|
||||||
<MalioButton
|
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
|
||||||
variant="primary"
|
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
|
||||||
:label="t('logistique.weighingTickets.form.save')"
|
<MalioInputText
|
||||||
:disabled="creating"
|
:model-value="form.immatriculation.value"
|
||||||
@click="submitCreate"
|
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
|
||||||
/>
|
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.immatriculation"
|
||||||
|
@update:model-value="(v: string | null) => form.immatriculation.value = v"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
id="plate-free-format"
|
||||||
|
:model-value="form.plateFreeFormat.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||||
|
group-class="self-center"
|
||||||
|
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────-->
|
<!-- ── Bloc « Poids à vide » ──────────────────────────────────────── -->
|
||||||
<WeighingBlock
|
<WeighingBlock
|
||||||
|
class="py-[20px]"
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:errors="emptyBlockErrors"
|
||||||
|
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||||
|
@request-auto="openAuto('empty')"
|
||||||
|
@request-manual="openManual('empty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
|
||||||
|
pour ne pas écarter le bouton « Valider »). ───────────────────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
class="pt-[20px]"
|
||||||
block-id="full"
|
block-id="full"
|
||||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
:block="form.full"
|
:block="form.full"
|
||||||
:immatriculation="form.immatriculation.value"
|
|
||||||
:plate-free-format="form.plateFreeFormat.value"
|
|
||||||
:errors="fullBlockErrors"
|
:errors="fullBlockErrors"
|
||||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
|
||||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
|
||||||
@request-auto="openAuto('full')"
|
@request-auto="openAuto('full')"
|
||||||
@request-manual="openManual('full')"
|
@request-manual="openManual('full')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du
|
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
|
||||||
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. -->
|
du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
|
||||||
|
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
|
||||||
<div class="mt-12 flex justify-center">
|
<div class="mt-12 flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('logistique.weighingTickets.form.validate')"
|
:label="t('logistique.weighingTickets.form.validate')"
|
||||||
:disabled="validating || form.ticketId.value === null"
|
:disabled="validating"
|
||||||
@click="submitValidate"
|
@click="submitValidate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
<!-- La question est portée par le titre ; pas de texte de corps. Bouton
|
|
||||||
« Valider » seul, centré (l'annulation se fait via la croix). -->
|
|
||||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
</template>
|
</template>
|
||||||
<!-- Erreur de pont indisponible affichée INLINE dans la modal + invite
|
|
||||||
à la pesée manuelle (RG-5.06). -->
|
|
||||||
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -134,9 +137,6 @@
|
|||||||
</MalioModal>
|
</MalioModal>
|
||||||
|
|
||||||
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
<!-- Marges : titre UPPERCASE à 24px du haut (pt-6), 28px horizontaux (mx-7 header
|
|
||||||
/ px-7 body+footer), bordure à 12px sous le titre (pb-3) et insérée (mx-7,
|
|
||||||
ne touche pas les bords), formulaire à 36px sous la bordure (pt-9). -->
|
|
||||||
<MalioModal
|
<MalioModal
|
||||||
v-model="manualModal.open"
|
v-model="manualModal.open"
|
||||||
modal-class="max-w-md"
|
modal-class="max-w-md"
|
||||||
@@ -148,7 +148,6 @@
|
|||||||
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- Poids : champ texte verrouillé sur les chiffres (comme le formulaire). -->
|
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="manualModal.weight"
|
v-model="manualModal.weight"
|
||||||
:mask="NUMERIC_MASK"
|
:mask="NUMERIC_MASK"
|
||||||
@@ -180,7 +179,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
|
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -197,28 +196,8 @@ if (!can('logistique.weighing_tickets.manage')) {
|
|||||||
const form = useWeighingTicketForm()
|
const form = useWeighingTicketForm()
|
||||||
const weighbridge = useWeighbridge()
|
const weighbridge = useWeighbridge()
|
||||||
const referentials = useWeighingTicketReferentials()
|
const referentials = useWeighingTicketReferentials()
|
||||||
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation front-only de la pesée d'un bloc (Poids + DSD obligatoires, RG-5.07).
|
|
||||||
* Le back rend ces colonnes nullable (workflow 2 temps), l'obligation est donc
|
|
||||||
* portée côté front (ERP-101). Pose l'erreur inline sous chaque champ manquant et
|
|
||||||
* retourne false si une pesée manque.
|
|
||||||
*/
|
|
||||||
function validateWeighing(which: 'empty' | 'full'): boolean {
|
|
||||||
const missing = form.missingWeighingFields(which)
|
|
||||||
for (const path of missing) {
|
|
||||||
setError(path, path.endsWith('Weight')
|
|
||||||
? t('logistique.weighingTickets.form.weightRequired')
|
|
||||||
: t('logistique.weighingTickets.form.dsdRequired'))
|
|
||||||
}
|
|
||||||
return missing.length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués).
|
|
||||||
const emptyLocked = computed(() => form.ticketId.value !== null)
|
|
||||||
|
|
||||||
const creating = ref(false)
|
|
||||||
const validating = ref(false)
|
const validating = ref(false)
|
||||||
|
|
||||||
/** Retour vers la liste (flèche d'en-tête). */
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
@@ -226,8 +205,7 @@ function goBack(): void {
|
|||||||
router.push('/weighing-tickets')
|
router.push('/weighing-tickets')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
|
||||||
// Ordre maquette : Fournisseur / Client / Autre.
|
|
||||||
const counterpartyOptions = computed<RefOption[]>(() => [
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
@@ -244,12 +222,7 @@ const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
|||||||
date: errors.emptyDate,
|
date: errors.emptyDate,
|
||||||
weight: errors.emptyWeight,
|
weight: errors.emptyWeight,
|
||||||
dsd: errors.emptyDsd,
|
dsd: errors.emptyDsd,
|
||||||
immatriculation: errors.immatriculation,
|
|
||||||
}))
|
}))
|
||||||
// Immatriculation volontairement ABSENTE ici : elle est partagée entre les 2 blocs
|
|
||||||
// (RG-5.01) mais saisie/validée sur le bloc « Poids à vide ». On n'affiche donc
|
|
||||||
// son erreur que sur le 1er bloc, pas en double sur le bloc « Poids à plein »
|
|
||||||
// (le formulaire se valide en 2 temps).
|
|
||||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
date: errors.fullDate,
|
date: errors.fullDate,
|
||||||
weight: errors.fullWeight,
|
weight: errors.fullWeight,
|
||||||
@@ -258,7 +231,6 @@ const fullBlockErrors = computed<Record<string, string>>(() => ({
|
|||||||
|
|
||||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||||
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||||
// Affectation typée via l'index du bloc reactif (date est le seul champ éditable).
|
|
||||||
(form[target] as Record<string, unknown>)[field as string] = value
|
(form[target] as Record<string, unknown>)[field as string] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +248,7 @@ function openAuto(target: 'empty' | 'full'): void {
|
|||||||
autoModal.open = true
|
autoModal.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */
|
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
|
||||||
async function confirmAuto(): Promise<void> {
|
async function confirmAuto(): Promise<void> {
|
||||||
if (autoModal.loading) return
|
if (autoModal.loading) return
|
||||||
autoModal.loading = true
|
autoModal.loading = true
|
||||||
@@ -285,9 +257,9 @@ async function confirmAuto(): Promise<void> {
|
|||||||
const reading = await weighbridge.triggerAuto()
|
const reading = await weighbridge.triggerAuto()
|
||||||
form.applyReading(form[autoModal.target], reading)
|
form.applyReading(form[autoModal.target], reading)
|
||||||
autoModal.open = false
|
autoModal.open = false
|
||||||
|
await saveDraft()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
// Pont indisponible : message inline + invite à la pesée manuelle.
|
|
||||||
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -313,7 +285,7 @@ function openManual(target: 'empty' | 'full'): void {
|
|||||||
manualModal.open = true
|
manualModal.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */
|
/** Valide la saisie manuelle, remplit le bloc puis enregistre le brouillon. */
|
||||||
async function confirmManual(): Promise<void> {
|
async function confirmManual(): Promise<void> {
|
||||||
if (manualModal.loading) return
|
if (manualModal.loading) return
|
||||||
manualModal.errors = {}
|
manualModal.errors = {}
|
||||||
@@ -333,6 +305,7 @@ async function confirmManual(): Promise<void> {
|
|||||||
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||||
form.applyReading(form[manualModal.target], reading)
|
form.applyReading(form[manualModal.target], reading)
|
||||||
manualModal.open = false
|
manualModal.open = false
|
||||||
|
await saveDraft()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
|
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
|
||||||
@@ -342,45 +315,47 @@ async function confirmManual(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Soumissions ──────────────────────────────────────────────────────────────
|
// ── Persistance ──────────────────────────────────────────────────────────────
|
||||||
interface TicketResponse { id: number }
|
interface TicketResponse { id: number }
|
||||||
|
|
||||||
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
|
/**
|
||||||
async function submitCreate(): Promise<void> {
|
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
|
||||||
if (creating.value) return
|
* encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
|
||||||
|
* mappée inline, ex. format d'immatriculation).
|
||||||
|
*/
|
||||||
|
async function saveDraft(): Promise<boolean> {
|
||||||
clearErrors()
|
clearErrors()
|
||||||
// Marque Poids/DSD manquants pour un retour immédiat, mais on POSTe quand même :
|
|
||||||
// le back renvoie TOUTES les violations d'un coup (counterparty / immat / poids,
|
|
||||||
// NotBlank sur emptyWeight), comme les autres modules. Le DSD est alloué serveur
|
|
||||||
// (pas de règle back) → simple repère front en miroir du poids.
|
|
||||||
validateWeighing('empty')
|
|
||||||
creating.value = true
|
|
||||||
try {
|
try {
|
||||||
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
|
if (form.ticketId.value === null) {
|
||||||
headers: { Accept: 'application/ld+json' },
|
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
|
||||||
toast: false,
|
headers: { Accept: 'application/ld+json' },
|
||||||
})
|
toast: false,
|
||||||
form.ticketId.value = created.id
|
})
|
||||||
|
form.ticketId.value = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
}
|
return false
|
||||||
finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
|
/**
|
||||||
|
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
|
||||||
|
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
|
||||||
|
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
|
||||||
|
*/
|
||||||
async function submitValidate(): Promise<void> {
|
async function submitValidate(): Promise<void> {
|
||||||
if (validating.value || form.ticketId.value === null) return
|
if (validating.value) return
|
||||||
clearErrors()
|
|
||||||
// Pesée à plein obligatoire (front-only) avant finalisation/impression.
|
|
||||||
if (!validateWeighing('full')) return
|
|
||||||
validating.value = true
|
validating.value = true
|
||||||
try {
|
try {
|
||||||
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
|
if (!(await saveDraft())) return
|
||||||
// Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne
|
|
||||||
// dessine aucun gabarit côté front (RG-5.08).
|
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
|
||||||
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||||
router.push('/weighing-tickets')
|
router.push('/weighing-tickets')
|
||||||
}
|
}
|
||||||
@@ -393,7 +368,6 @@ async function submitValidate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Échec du chargement des référentiels non bloquant : les selects restent vides.
|
|
||||||
referentials.load().catch(() => {})
|
referentials.load().catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M5 — Tickets de pesee (ERP-193) : cycle de vie brouillon -> valide.
|
||||||
|
*
|
||||||
|
* Le metier peut desormais enregistrer une pesee (bascule ou manuelle) SANS avoir
|
||||||
|
* rempli la contrepartie ni l'immatriculation : le ticket est cree « brouillon »
|
||||||
|
* des la 1ere pesee, puis « valide » (numero attribue, status VALIDATED) quand les
|
||||||
|
* 3 champs requis (type + champ contrepartie + immatriculation) ET les 2 pesees
|
||||||
|
* sont renseignes.
|
||||||
|
*
|
||||||
|
* Schema impacte :
|
||||||
|
* - `counterparty_type`, `immatriculation`, `number` passent NULLABLE (un brouillon
|
||||||
|
* n'a encore ni contrepartie, ni immat, ni numero — le numero n'est attribue
|
||||||
|
* qu'a la validation pour eviter les trous de sequence). Les CHECK de branche
|
||||||
|
* chk_wt_*_branch tolerent deja un counterparty_type NULL (NULL <> 'X' = NULL,
|
||||||
|
* donc CHECK non viole).
|
||||||
|
* - nouvelle colonne `status` (DRAFT|VALIDATED). Les tickets EXISTANTS (crees sous
|
||||||
|
* l'ancien flux, donc complets) sont retro-marques VALIDATED ; le defaut des
|
||||||
|
* nouvelles lignes est DRAFT.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (et non modulaire) : la migration ALTER une
|
||||||
|
* table creee par la migration racine Version20260617150000. Doctrine Migrations
|
||||||
|
* 3.x trie par FQCN alphabetique entre namespaces -> une migration modulaire
|
||||||
|
* `App\Module\...` passerait AVANT la racine sur base vide (make db-reset) et
|
||||||
|
* tenterait l'ALTER avant le CREATE. Le namespace racine garantit le tri par
|
||||||
|
* timestamp (regle ABSOLUE n°11, cf. Version20260617170000 pour site.code).
|
||||||
|
*/
|
||||||
|
final class Version20260624100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-193 : weighing_ticket brouillon/valide (counterparty_type/immatriculation/number nullable + colonne status).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Brouillon : ni contrepartie, ni immat, ni numero tant que non valide.
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type DROP NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation DROP NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number DROP NOT NULL');
|
||||||
|
|
||||||
|
// Statut du cycle de vie. Colonne ajoutee nullable, retro-remplie a VALIDATED
|
||||||
|
// pour les tickets existants (complets), puis figee NOT NULL DEFAULT DRAFT.
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ADD COLUMN status VARCHAR(12)');
|
||||||
|
$this->addSql("UPDATE weighing_ticket SET status = 'VALIDATED'");
|
||||||
|
$this->addSql("ALTER TABLE weighing_ticket ALTER COLUMN status SET DEFAULT 'DRAFT'");
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN status SET NOT NULL');
|
||||||
|
$this->addSql("ALTER TABLE weighing_ticket ADD CONSTRAINT chk_wt_status CHECK (status IN ('DRAFT','VALIDATED'))");
|
||||||
|
|
||||||
|
// Commentaires (regle ABSOLUE n°12).
|
||||||
|
$this->comment('weighing_ticket', 'status', "Cycle de vie : DRAFT (En attente, pesee enregistree sans contrepartie/immat) ou VALIDATED (Terminee, valide avec numero). Defaut DRAFT.");
|
||||||
|
$this->comment('weighing_ticket', 'number', "Numero {siteCode}-TP-{NNNN}, unique par site, immuable. NULL tant que le ticket est brouillon : attribue a la validation (RG-5.02, ERP-193).");
|
||||||
|
$this->comment('weighing_ticket', 'counterparty_type', "Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). NULL tant que brouillon ; requise a la validation. Pilote l'obligation client_id / supplier_id / other_label.");
|
||||||
|
$this->comment('weighing_ticket', 'immatriculation', "Plaque du vehicule, partagee entre pesee vide et plein (RG-5.01). NULL tant que brouillon ; requise a la validation. Masque XX-000-XX sauf plate_free_format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP CONSTRAINT IF EXISTS chk_wt_status');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket DROP COLUMN IF EXISTS status');
|
||||||
|
|
||||||
|
// Restauration NOT NULL : echoue s'il subsiste des brouillons (number /
|
||||||
|
// counterparty_type / immatriculation NULL) — irreversible en presence de
|
||||||
|
// donnees brouillon, ce qui est attendu (le down sert au dev sur base saine).
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN number SET NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN immatriculation SET NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE weighing_ticket ALTER COLUMN counterparty_type SET NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose un COMMENT ON COLUMN en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||||
|
* tout echappement d'apostrophes dans les descriptions.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
'"'.str_replace('"', '""', $table).'"',
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,29 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
provider: WeighingTicketProvider::class,
|
provider: WeighingTicketProvider::class,
|
||||||
processor: WeighingTicketProcessor::class,
|
processor: WeighingTicketProcessor::class,
|
||||||
),
|
),
|
||||||
|
// Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule
|
||||||
|
// operation qui exige le groupe `finalize` (contrepartie + immatriculation +
|
||||||
|
// les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status
|
||||||
|
// a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation
|
||||||
|
// Default relachee, on enregistre une pesee sans contrepartie/immat).
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/weighing_tickets/{id}/validate',
|
||||||
|
name: 'weighing_ticket_validate',
|
||||||
|
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||||
|
normalizationContext: ['groups' => [
|
||||||
|
'weighing_ticket:read',
|
||||||
|
'weighing_ticket:item:read',
|
||||||
|
'client:read',
|
||||||
|
'supplier:read',
|
||||||
|
'site:read',
|
||||||
|
'default:read',
|
||||||
|
]],
|
||||||
|
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||||
|
validationContext: ['groups' => ['Default', 'finalize']],
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
|
provider: WeighingTicketProvider::class,
|
||||||
|
processor: WeighingTicketProcessor::class,
|
||||||
|
),
|
||||||
// Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx).
|
// Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx).
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
@@ -146,14 +169,20 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
/** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */
|
||||||
|
public const string STATUS_DRAFT = 'DRAFT';
|
||||||
|
|
||||||
|
/** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */
|
||||||
|
public const string STATUS_VALIDATED = 'VALIDATED';
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */
|
/** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */
|
||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?string $number = null;
|
private ?string $number = null;
|
||||||
|
|
||||||
@@ -163,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:item:read'])]
|
#[Groups(['weighing_ticket:item:read'])]
|
||||||
private ?Site $site = null;
|
private ?Site $site = null;
|
||||||
|
|
||||||
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */
|
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */
|
||||||
#[ORM\Column(name: 'counterparty_type', length: 12)]
|
#[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
|
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])]
|
||||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
private ?string $counterpartyType = null;
|
private ?string $counterpartyType = null;
|
||||||
@@ -188,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||||
private ?string $otherLabel = null;
|
private ?string $otherLabel = null;
|
||||||
|
|
||||||
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */
|
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */
|
||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')]
|
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])]
|
||||||
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?string $immatriculation = null;
|
private ?string $immatriculation = null;
|
||||||
@@ -210,13 +239,11 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
|
* Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).
|
||||||
* Obligatoire : un ticket est cree APRES la pesee a vide (POST). NotBlank ici
|
* Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord,
|
||||||
* (et non sur empty_dsd, alloue serveur) rend la 422 « poids obligatoire »
|
* ERP-193). L'obligation des DEUX pesees est portee par validateFinalization
|
||||||
* coherente avec les autres champs requis (counterpartyType / immatriculation),
|
* (groupe `finalize`), jouee uniquement a la validation.
|
||||||
* toutes renvoyees d'un coup -> mapping inline front (ERP-101).
|
|
||||||
*/
|
*/
|
||||||
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
||||||
#[Assert\NotBlank(message: 'Le poids est obligatoire : effectuez une pesée.')]
|
|
||||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||||
private ?int $emptyWeight = null;
|
private ?int $emptyWeight = null;
|
||||||
|
|
||||||
@@ -268,6 +295,16 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
#[Groups(['weighing_ticket:read'])]
|
#[Groups(['weighing_ticket:read'])]
|
||||||
private ?int $netWeight = null;
|
private ?int $netWeight = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans
|
||||||
|
* contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose
|
||||||
|
* serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de
|
||||||
|
* groupe d'ecriture (jamais pilote par le client).
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])]
|
||||||
|
#[Groups(['weighing_ticket:read'])]
|
||||||
|
private string $status = self::STATUS_DRAFT;
|
||||||
|
|
||||||
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
|
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
|
||||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
private ?DateTimeImmutable $deletedAt = null;
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
@@ -284,7 +321,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
||||||
* champs hors-branche — ERP-185).
|
* champs hors-branche — ERP-185).
|
||||||
*/
|
*/
|
||||||
#[Assert\Callback]
|
#[Assert\Callback(groups: ['finalize'])]
|
||||||
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
switch ($this->counterpartyType) {
|
switch ($this->counterpartyType) {
|
||||||
@@ -320,6 +357,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec
|
||||||
|
* ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que
|
||||||
|
* complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ;
|
||||||
|
* un brouillon peut ne porter qu'une seule pesee. Violations posees sur les
|
||||||
|
* champs poids -> mapping inline front (useFormErrors, ERP-101).
|
||||||
|
*/
|
||||||
|
#[Assert\Callback(groups: ['finalize'])]
|
||||||
|
public function validateFinalization(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null === $this->emptyWeight) {
|
||||||
|
$context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.')
|
||||||
|
->atPath('emptyWeight')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $this->fullWeight) {
|
||||||
|
$context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.')
|
||||||
|
->atPath('fullWeight')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
|
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
|
||||||
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
|
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
|
||||||
@@ -568,6 +630,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): static
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValidated(): bool
|
||||||
|
{
|
||||||
|
return self::STATUS_VALIDATED === $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
public function getDeletedAt(): ?DateTimeImmutable
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->deletedAt;
|
return $this->deletedAt;
|
||||||
|
|||||||
+17
-5
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
|
// Une entite non geree par l'ORM = creation (POST). On rattache le site
|
||||||
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
|
// courant (cloisonnement + base de la numerotation), immuable ensuite
|
||||||
|
// (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon »
|
||||||
|
// (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193).
|
||||||
$isNew = !$this->em->contains($data);
|
$isNew = !$this->em->contains($data);
|
||||||
|
|
||||||
if ($isNew) {
|
if ($isNew) {
|
||||||
$site = $this->resolveCurrentSite();
|
$data->setSite($this->resolveCurrentSite());
|
||||||
$data->setSite($site);
|
|
||||||
$data->setNumber($this->numberAllocator->allocate($site));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyCounterpartyExclusivity($data);
|
$this->applyCounterpartyExclusivity($data);
|
||||||
@@ -89,6 +89,18 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
$this->computeNetWeight($data);
|
$this->computeNetWeight($data);
|
||||||
|
|
||||||
|
// Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide.
|
||||||
|
// La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a
|
||||||
|
// deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur
|
||||||
|
// verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on
|
||||||
|
// passe le statut a VALIDATED.
|
||||||
|
if ('weighing_ticket_validate' === $operation->getName()) {
|
||||||
|
if (null === $data->getNumber() && $site instanceof Site) {
|
||||||
|
$data->setNumber($this->numberAllocator->allocate($site));
|
||||||
|
}
|
||||||
|
$data->setStatus(WeighingTicket::STATUS_VALIDATED);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -556,12 +556,12 @@ final class ColumnCommentsCatalog
|
|||||||
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
||||||
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).',
|
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).',
|
||||||
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.',
|
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. Pilote l obligation client_id / supplier_id / other_label.',
|
||||||
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
||||||
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
||||||
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
||||||
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
||||||
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
||||||
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
||||||
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||||
@@ -574,6 +574,7 @@ final class ColumnCommentsCatalog
|
|||||||
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
||||||
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
|
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
|
||||||
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
||||||
|
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
|
||||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
|
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
|
||||||
* l'appelant).
|
* l'appelant). Cree un BROUILLON (status DRAFT, sans numero, ERP-193) — la
|
||||||
|
* validation est portee par validateTicket().
|
||||||
*/
|
*/
|
||||||
protected function postTicket(Client $http, array $payload): ResponseInterface
|
protected function postTicket(Client $http, array $payload): ResponseInterface
|
||||||
{
|
{
|
||||||
@@ -230,6 +231,32 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Valider » un ticket : PATCH /weighing_tickets/{id}/validate (ERP-193).
|
||||||
|
* Declenche la validation stricte (groupe finalize) + attribution du numero +
|
||||||
|
* passage en VALIDATED. Body vide par defaut = on valide l'etat deja persiste.
|
||||||
|
*/
|
||||||
|
protected function validateTicket(Client $http, int $id, array $payload = []): ResponseInterface
|
||||||
|
{
|
||||||
|
return $http->request('PATCH', '/api/weighing_tickets/'.$id.'/validate', [
|
||||||
|
'headers' => ['Content-Type' => self::MERGE],
|
||||||
|
'json' => [] === $payload ? new \stdClass() : $payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST un brouillon complet puis le valide ; renvoie le ticket VALIDE (numero
|
||||||
|
* attribue). Le payload doit porter contrepartie + immatriculation + 2 pesees.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function createValidatedTicket(Client $http, array $payload): array
|
||||||
|
{
|
||||||
|
$id = (int) $this->postTicket($http, $payload)->toArray()['id'];
|
||||||
|
|
||||||
|
return $this->validateTicket($http, $id)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrouve un membre d'une collection Hydra par son id.
|
* Retrouve un membre d'une collection Hydra par son id.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Logistique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle de vie brouillon -> valide du ticket de pesee (ERP-193, spec-back § 2.14).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - une pesee peut etre enregistree SANS contrepartie ni immatriculation : le POST
|
||||||
|
* cree un BROUILLON (status DRAFT, pas de numero) ;
|
||||||
|
* - la validation (PATCH /validate) exige les 3 champs du haut (type + champ
|
||||||
|
* contrepartie + immatriculation) ET les 2 pesees (groupe `finalize`) ;
|
||||||
|
* - une validation complete attribue le numero {siteCode}-TP-{NNNN} et passe le
|
||||||
|
* ticket en VALIDATED.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WeighingTicketLifecycleTest extends AbstractWeighingTicketApiTestCase
|
||||||
|
{
|
||||||
|
public function testWeighingOnlyCreatesDraftWithoutNumber(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Pesee a vide seule : ni contrepartie, ni immatriculation.
|
||||||
|
$body = $this->postTicket($http, [
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertSame('DRAFT', $body['status']);
|
||||||
|
self::assertArrayNotHasKey('number', $body, 'Un brouillon n\'a pas encore de numero (skip_null_values).');
|
||||||
|
self::assertSame(7150, $body['emptyWeight']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRequiresCounterparty(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
|
||||||
|
// Brouillon complet cote pesees + immatriculation, mais SANS contrepartie.
|
||||||
|
$id = (int) $this->postTicket($http, [
|
||||||
|
'immatriculation' => 'AB-123-CD',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
'fullDate' => '2026-06-17T09:12:00+02:00',
|
||||||
|
'fullWeight' => 14300,
|
||||||
|
'fullMode' => 'AUTO',
|
||||||
|
])->toArray()['id'];
|
||||||
|
|
||||||
|
$response = $this->validateTicket($http, $id);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertViolationOnPath($response, 'counterpartyType');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateRequiresBothWeighings(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
$client = $this->seedTestClient('Lifecycle');
|
||||||
|
|
||||||
|
// Brouillon avec contrepartie + immat + UNE seule pesee (a vide).
|
||||||
|
$id = (int) $this->postTicket($http, [
|
||||||
|
'counterpartyType' => 'CLIENT',
|
||||||
|
'client' => $this->clientIri($client),
|
||||||
|
'immatriculation' => 'AB-123-CD',
|
||||||
|
'emptyDate' => '2026-06-17T09:00:00+02:00',
|
||||||
|
'emptyWeight' => 7150,
|
||||||
|
'emptyMode' => 'AUTO',
|
||||||
|
])->toArray()['id'];
|
||||||
|
|
||||||
|
$response = $this->validateTicket($http, $id);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertViolationOnPath($response, 'fullWeight');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateAssignsNumberAndStatus(): void
|
||||||
|
{
|
||||||
|
$http = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
|
$client = $this->seedTestClient('LifecycleOk');
|
||||||
|
|
||||||
|
$validated = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
|
|
||||||
|
self::assertSame('VALIDATED', $validated['status']);
|
||||||
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', (string) $validated['number']);
|
||||||
|
self::assertSame(7150, $validated['netWeight']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,12 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$client = $this->seedTestClient('Num');
|
$client = $this->seedTestClient('Num');
|
||||||
|
|
||||||
$first = $this->postTicket($http, $this->validClientTicketPayload($client));
|
// Le numero est attribue a la VALIDATION (brouillon -> valide, ERP-193).
|
||||||
self::assertResponseStatusCodeSame(201);
|
$first = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
$second = $this->postTicket($http, $this->validClientTicketPayload($client));
|
$second = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
|
|
||||||
$n1 = (string) $first->toArray()['number'];
|
$n1 = (string) $first['number'];
|
||||||
$n2 = (string) $second->toArray()['number'];
|
$n2 = (string) $second['number'];
|
||||||
|
|
||||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
|
||||||
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
|
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
|
||||||
@@ -49,8 +48,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http86 = $this->authManageOnSite($this->siteByCode('86'));
|
$http86 = $this->authManageOnSite($this->siteByCode('86'));
|
||||||
$http17 = $this->authManageOnSite($this->siteByCode('17'));
|
$http17 = $this->authManageOnSite($this->siteByCode('17'));
|
||||||
|
|
||||||
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number'];
|
$n86 = (string) $this->createValidatedTicket($http86, $this->validClientTicketPayload($client))['number'];
|
||||||
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number'];
|
$n17 = (string) $this->createValidatedTicket($http17, $this->validClientTicketPayload($client))['number'];
|
||||||
|
|
||||||
// Chaque site encode son propre code dans le numero ; sequences disjointes.
|
// Chaque site encode son propre code dans le numero ; sequences disjointes.
|
||||||
self::assertStringStartsWith('86-TP-', $n86);
|
self::assertStringStartsWith('86-TP-', $n86);
|
||||||
@@ -63,7 +62,8 @@ final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCas
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$client = $this->seedTestClient('Immutable');
|
$client = $this->seedTestClient('Immutable');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray();
|
// Ticket valide (numero attribue) puis tentative de re-ecriture.
|
||||||
|
$created = $this->createValidatedTicket($http, $this->validClientTicketPayload($client));
|
||||||
$id = (int) $created['id'];
|
$id = (int) $created['id'];
|
||||||
$number = (string) $created['number'];
|
$number = (string) $created['number'];
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$clientEntity = $this->seedTestClient('Negoce');
|
$clientEntity = $this->seedTestClient('Negoce');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity));
|
// Brouillon cree puis valide (numero attribue a la validation, ERP-193).
|
||||||
self::assertResponseStatusCodeSame(201);
|
$createdBody = $this->createValidatedTicket($http, $this->validClientTicketPayload($clientEntity));
|
||||||
$createdBody = $created->toArray();
|
|
||||||
|
|
||||||
$id = (int) $createdBody['id'];
|
$id = (int) $createdBody['id'];
|
||||||
$number = (string) $createdBody['number'];
|
$number = (string) $createdBody['number'];
|
||||||
|
self::assertSame('VALIDATED', $createdBody['status']);
|
||||||
|
|
||||||
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
@@ -69,6 +69,9 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
|
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
|
||||||
self::assertArrayHasKey('displayDate', $row);
|
self::assertArrayHasKey('displayDate', $row);
|
||||||
|
|
||||||
|
// Statut du cycle de vie expose en liste (colonne « En attente / Terminée »).
|
||||||
|
self::assertSame('VALIDATED', $row['status']);
|
||||||
|
|
||||||
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
|
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
|
||||||
self::assertIsArray($detail['site']);
|
self::assertIsArray($detail['site']);
|
||||||
self::assertSame('86', $detail['site']['code']);
|
self::assertSame('86', $detail['site']['code']);
|
||||||
@@ -95,9 +98,7 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
|
|||||||
$http = $this->authManageOnSite($site);
|
$http = $this->authManageOnSite($site);
|
||||||
$supplierEntity = $this->seedTestSupplier('Ferraille');
|
$supplierEntity = $this->seedTestSupplier('Ferraille');
|
||||||
|
|
||||||
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
|
$createdBody = $this->createValidatedTicket($http, $this->validSupplierTicketPayload($supplierEntity));
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
$createdBody = $created->toArray();
|
|
||||||
|
|
||||||
$id = (int) $createdBody['id'];
|
$id = (int) $createdBody['id'];
|
||||||
$number = (string) $createdBody['number'];
|
$number = (string) $createdBody['number'];
|
||||||
|
|||||||
+5
-2
@@ -145,14 +145,17 @@ final class CounterpartyValidationTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des propertyPath des violations de l'entite.
|
* Liste des propertyPath des violations de l'entite, validee dans le groupe
|
||||||
|
* `finalize` (la coherence contrepartie ne joue qu'a la validation depuis
|
||||||
|
* ERP-193 ; un brouillon peut ne pas porter de contrepartie). Miroir du
|
||||||
|
* validationContext de l'operation `validate` (['Default', 'finalize']).
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
private function violationPaths(WeighingTicket $ticket): array
|
private function violationPaths(WeighingTicket $ticket): array
|
||||||
{
|
{
|
||||||
$paths = [];
|
$paths = [];
|
||||||
foreach ($this->validator->validate($ticket) as $violation) {
|
foreach ($this->validator->validate($ticket, null, ['Default', 'finalize']) as $violation) {
|
||||||
$paths[] = $violation->getPropertyPath();
|
$paths[] = $violation->getPropertyPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user