Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f3fe4da4e | |||
| ef7bf69980 | |||
| 117dcdbdcc |
+21
-20
@@ -38,7 +38,27 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
|
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||||
|
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
||||||
|
// Administration cote develop). Porte le ticket de pesee au pont bascule.
|
||||||
|
// Placee en tete de sidebar (avant Commerciale). L'item est gate par
|
||||||
|
// `logistique.weighing_tickets.view` ; la section disparait automatiquement
|
||||||
|
// (SidebarProvider) si le module `logistique` est desactive ou si l'user n'a
|
||||||
|
// pas la permission (Compta / Commerciale).
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.logistique.section',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.logistique.weighing_tickets',
|
||||||
|
'to' => '/weighing-tickets',
|
||||||
|
'icon' => 'mdi:truck-outline',
|
||||||
|
'module' => 'logistique',
|
||||||
|
'permission' => 'logistique.weighing_tickets.view',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Section "Commerciale" : pole metier principal (ERP-71).
|
||||||
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
||||||
// du bloc, aucun gate touche).
|
// du bloc, aucun gate touche).
|
||||||
[
|
[
|
||||||
@@ -78,25 +98,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
|
||||||
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
|
||||||
// Administration cote develop). Porte le ticket de pesee au pont bascule.
|
|
||||||
// L'item est gate par `logistique.weighing_tickets.view` ; la section disparait
|
|
||||||
// automatiquement (SidebarProvider) si le module `logistique` est desactive ou
|
|
||||||
// si l'user n'a pas la permission (Compta / Commerciale).
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.logistique.section',
|
|
||||||
'icon' => 'mdi:scale',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.logistique.weighing_tickets',
|
|
||||||
'to' => '/weighing-tickets',
|
|
||||||
'icon' => 'mdi:scale',
|
|
||||||
'module' => 'logistique',
|
|
||||||
'permission' => 'logistique.weighing_tickets.view',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -691,6 +691,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"logistique": {
|
||||||
|
"weighingTickets": {
|
||||||
|
"title": "Tickets de pesée",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun ticket de pesée pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"number": "Numéro",
|
||||||
|
"client": "Client",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"other": "Autre",
|
||||||
|
"date": "Date",
|
||||||
|
"weight": "Poids"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"back": "Retour à la liste",
|
||||||
|
"addTitle": "Ajouter un ticket de pesée",
|
||||||
|
"emptyBlock": "Poids à vide",
|
||||||
|
"fullBlock": "Poids à plein",
|
||||||
|
"date": "Date",
|
||||||
|
"weight": "Poids (Kg)",
|
||||||
|
"dsd": "DSD",
|
||||||
|
"immatriculation": "Immatriculation",
|
||||||
|
"plateFreeFormat": "Tout format",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"validate": "Valider",
|
||||||
|
"counterparty": {
|
||||||
|
"type": "Fournisseur / Client / Autre",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"client": "Client",
|
||||||
|
"other": "Autre"
|
||||||
|
},
|
||||||
|
"weighbridge": {
|
||||||
|
"auto": "Pesée bascule",
|
||||||
|
"manual": "Pesée manuelle",
|
||||||
|
"confirmTitle": "Pesée bascule",
|
||||||
|
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"validate": "Valider",
|
||||||
|
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
|
||||||
|
},
|
||||||
|
"manual": {
|
||||||
|
"title": "Pesée manuelle",
|
||||||
|
"weight": "Poids (Kg)",
|
||||||
|
"number": "Numéro de pesée",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"weightRequired": "Le poids est obligatoire.",
|
||||||
|
"numberRequired": "Le numéro de pesée est obligatoire."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Deconnexion",
|
"logout": "Deconnexion",
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('request-auto')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="$emit('request-manual')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
|
||||||
|
<slot name="counterparty" />
|
||||||
|
|
||||||
|
<!-- Date de la pesée — jour par défaut (RG-5.07). MalioDate (composant
|
||||||
|
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
|
||||||
|
<MalioDate
|
||||||
|
: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)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
|
||||||
|
<MalioInputNumber
|
||||||
|
:model-value="block.weight"
|
||||||
|
:label="t('logistique.weighingTickets.form.weight')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="true"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors.weight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
|
||||||
|
<MalioInputNumber
|
||||||
|
:model-value="block.dsd"
|
||||||
|
:label="t('logistique.weighingTickets.form.dsd')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="true"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error="errors.dsd"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
|
||||||
|
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 ? undefined : 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)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) :
|
||||||
|
* portés par le form parent et remontés en `update:*`. Le slot `counterparty`
|
||||||
|
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Masque plaque FR SIV `XX-000-XX` (maska) : 2 lettres, 3 chiffres, 2 lettres,
|
||||||
|
// majuscules forcées. Désactivé quand « Tout format » est coché (RG-5.01).
|
||||||
|
const PLATE_MASK = {
|
||||||
|
mask: 'AA-###-AA',
|
||||||
|
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||||
|
blockId: string
|
||||||
|
title: string
|
||||||
|
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). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
||||||
|
'update:immatriculation': [value: string | null]
|
||||||
|
'update:plateFreeFormat': [value: boolean]
|
||||||
|
'request-auto': []
|
||||||
|
'request-manual': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const errors = computed(() => props.errors ?? {})
|
||||||
|
|
||||||
|
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
|
||||||
|
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
|
||||||
|
emit('update:block', field, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ post: mockPost }))
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
|
||||||
|
const { useWeighbridge } = await import('../useWeighbridge')
|
||||||
|
|
||||||
|
describe('useWeighbridge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
|
||||||
|
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||||
|
const { triggerAuto } = useWeighbridge()
|
||||||
|
|
||||||
|
const reading = await triggerAuto()
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'AUTO' },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => {
|
||||||
|
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' })
|
||||||
|
const { triggerManual } = useWeighbridge()
|
||||||
|
|
||||||
|
const reading = await triggerManual(5000, 'PAP-555')
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(reading.dsd).toBe(43)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
|
||||||
|
const { extractWeighbridgeError } = useWeighbridge()
|
||||||
|
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
|
||||||
|
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
|
||||||
|
const { extractWeighbridgeError } = useWeighbridge()
|
||||||
|
expect(extractWeighbridgeError(new Error('network')))
|
||||||
|
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
|
||||||
|
mockPost.mockRejectedValue({ response: { status: 503 } })
|
||||||
|
const { triggerAuto } = useWeighbridge()
|
||||||
|
await expect(triggerAuto()).rejects.toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe.
|
||||||
|
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' }))
|
||||||
|
|
||||||
|
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
|
||||||
|
|
||||||
|
describe('useWeighingTicketForm', () => {
|
||||||
|
it('initialise les 2 blocs à la date du jour (RG-5.07), sans poids ni DSD', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
expect(form.empty.date).toBe('2026-06-22')
|
||||||
|
expect(form.full.date).toBe('2026-06-22')
|
||||||
|
expect(form.empty.weight).toBeNull()
|
||||||
|
expect(form.empty.dsd).toBeNull()
|
||||||
|
expect(form.counterpartyType.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
|
||||||
|
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.supplierIri.value = '/api/suppliers/3'
|
||||||
|
form.otherLabel.value = 'Particulier'
|
||||||
|
|
||||||
|
form.setCounterpartyType('CLIENT')
|
||||||
|
form.clientIri.value = '/api/clients/629'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('client')
|
||||||
|
expect(form.supplierIri.value).toBeNull()
|
||||||
|
expect(form.otherLabel.value).toBeNull()
|
||||||
|
|
||||||
|
const payload = form.buildCreatePayload()
|
||||||
|
expect(payload.counterpartyType).toBe('CLIENT')
|
||||||
|
expect(payload.client).toBe('/api/clients/629')
|
||||||
|
expect(payload).not.toHaveProperty('supplier')
|
||||||
|
expect(payload).not.toHaveProperty('otherLabel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FOURNISSEUR : ne conserve que le supplier', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.setCounterpartyType('FOURNISSEUR')
|
||||||
|
form.supplierIri.value = '/api/suppliers/7'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('supplier')
|
||||||
|
expect(form.clientIri.value).toBeNull()
|
||||||
|
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AUTRE : ne conserve que le libellé libre', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.setCounterpartyType('AUTRE')
|
||||||
|
form.otherLabel.value = 'Reprise interne'
|
||||||
|
|
||||||
|
expect(form.counterpartyField.value).toBe('other')
|
||||||
|
expect(form.clientIri.value).toBeNull()
|
||||||
|
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
||||||
|
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.immatriculation.value = 'AB-123-CD'
|
||||||
|
form.plateFreeFormat.value = true
|
||||||
|
|
||||||
|
// Les 2 payloads (création + finalisation) reflètent la même valeur.
|
||||||
|
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
|
||||||
|
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
|
||||||
|
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
|
||||||
|
expect(form.buildFullPayload().plateFreeFormat).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Application d'une lecture de pesée ────────────────────────────────────
|
||||||
|
it('applyReading remplit poids / DSD / mode du bloc visé', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
|
expect(form.empty.weight).toBe(7150)
|
||||||
|
expect(form.empty.dsd).toBe(1)
|
||||||
|
expect(form.empty.mode).toBe('AUTO')
|
||||||
|
expect(form.empty.manualNumber).toBeNull()
|
||||||
|
|
||||||
|
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' })
|
||||||
|
expect(form.full.weight).toBe(14300)
|
||||||
|
expect(form.full.manualNumber).toBe('PAP-555')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
form.setCounterpartyType('CLIENT')
|
||||||
|
form.clientIri.value = '/api/clients/1'
|
||||||
|
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||||
|
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
||||||
|
|
||||||
|
const create = form.buildCreatePayload()
|
||||||
|
expect(create.emptyWeight).toBe(7150)
|
||||||
|
expect(create.emptyDsd).toBe(1)
|
||||||
|
expect(create.emptyMode).toBe('AUTO')
|
||||||
|
expect(create).not.toHaveProperty('fullWeight')
|
||||||
|
|
||||||
|
const full = form.buildFullPayload()
|
||||||
|
expect(full.fullWeight).toBe(14300)
|
||||||
|
expect(full.fullDsd).toBe(2)
|
||||||
|
expect(full.fullMode).toBe('AUTO')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
|
||||||
|
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
|
||||||
|
* n'existe pas encore quand on pèse depuis le formulaire principal.
|
||||||
|
*
|
||||||
|
* Deux modes :
|
||||||
|
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
|
||||||
|
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
|
||||||
|
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
|
||||||
|
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur
|
||||||
|
* calcule le DSD = dernier + 1 (RG-5.04).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
|
||||||
|
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
|
||||||
|
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
|
||||||
|
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mode de pesée — miroir de l'enum back. */
|
||||||
|
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
|
||||||
|
|
||||||
|
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
|
||||||
|
export interface WeighbridgeReading {
|
||||||
|
weight: number
|
||||||
|
dsd: number
|
||||||
|
mode: WeighbridgeMode
|
||||||
|
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
|
||||||
|
manualNumber?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighbridge() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
|
||||||
|
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
|
||||||
|
* en toast global.
|
||||||
|
*/
|
||||||
|
async function triggerAuto(): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'AUTO' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ;
|
||||||
|
* le `manualNumber` est la référence du ticket papier / autre bascule.
|
||||||
|
*/
|
||||||
|
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> {
|
||||||
|
return await api.post<WeighbridgeReading>(
|
||||||
|
'/weighbridge_readings',
|
||||||
|
{ mode: 'MANUAL', weight, manualNumber },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
|
||||||
|
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
|
||||||
|
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
|
||||||
|
* générique invitant à la pesée manuelle.
|
||||||
|
*/
|
||||||
|
function extractWeighbridgeError(error: unknown): string {
|
||||||
|
const data = (error as { response?: { _data?: unknown } })?.response?._data as
|
||||||
|
| { detail?: string, title?: string }
|
||||||
|
| undefined
|
||||||
|
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { triggerAuto, triggerManual, extractWeighbridgeError }
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { todayIso } from '~/shared/utils/date'
|
||||||
|
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
|
||||||
|
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
|
||||||
|
* à plein — qui partagent un même véhicule.
|
||||||
|
*
|
||||||
|
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
|
||||||
|
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||||
|
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||||
|
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||||
|
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
|
||||||
|
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
|
||||||
|
* les 2 blocs bindent la même ref.
|
||||||
|
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
|
||||||
|
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
|
||||||
|
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
|
||||||
|
*
|
||||||
|
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||||
|
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
|
||||||
|
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
|
||||||
|
|
||||||
|
/** Saisie d'une pesée (bloc vide OU bloc plein). */
|
||||||
|
export interface WeighingBlockState {
|
||||||
|
/** Date de la pesée (ISO `YYYY-MM-DD`) — jour par défaut (RG-5.07). */
|
||||||
|
date: string | null
|
||||||
|
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
|
||||||
|
weight: number | null
|
||||||
|
/** DSD — readonly, rempli par la pesée (RG-5.04). */
|
||||||
|
dsd: number | null
|
||||||
|
/** Mode de la dernière pesée appliquée au bloc. */
|
||||||
|
mode: WeighbridgeMode | null
|
||||||
|
/** Numéro de pesée (rempli uniquement en pesée manuelle). */
|
||||||
|
manualNumber: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
|
||||||
|
function emptyBlock(today: string): WeighingBlockState {
|
||||||
|
return {
|
||||||
|
date: today,
|
||||||
|
weight: null,
|
||||||
|
dsd: null,
|
||||||
|
mode: null,
|
||||||
|
manualNumber: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeighingTicketForm() {
|
||||||
|
const today = todayIso()
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
|
||||||
|
const counterpartyType = ref<CounterpartyType | null>(null)
|
||||||
|
const clientIri = ref<string | null>(null)
|
||||||
|
const supplierIri = ref<string | null>(null)
|
||||||
|
const otherLabel = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le type de contrepartie et purge les champs devenus hors-sujet :
|
||||||
|
* un seul de client / supplier / otherLabel est conservé selon le type
|
||||||
|
* (RG-5.03 — pas de FK fantôme envoyée au back).
|
||||||
|
*/
|
||||||
|
function setCounterpartyType(type: CounterpartyType | null): void {
|
||||||
|
counterpartyType.value = type
|
||||||
|
if (type !== 'CLIENT') clientIri.value = null
|
||||||
|
if (type !== 'FOURNISSEUR') supplierIri.value = null
|
||||||
|
if (type !== 'AUTRE') otherLabel.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
|
||||||
|
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
|
||||||
|
const immatriculation = ref<string | null>(null)
|
||||||
|
const plateFreeFormat = ref<boolean>(false)
|
||||||
|
|
||||||
|
// ── Les deux pesées ───────────────────────────────────────────────────────
|
||||||
|
const empty = reactive<WeighingBlockState>(emptyBlock(today))
|
||||||
|
const full = reactive<WeighingBlockState>(emptyBlock(today))
|
||||||
|
|
||||||
|
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
|
||||||
|
const ticketId = ref<number | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||||
|
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||||
|
*/
|
||||||
|
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return 'client'
|
||||||
|
case 'FOURNISSEUR': return 'supplier'
|
||||||
|
case 'AUTRE': return 'other'
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
|
||||||
|
function applyReading(
|
||||||
|
block: WeighingBlockState,
|
||||||
|
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string },
|
||||||
|
): void {
|
||||||
|
block.weight = reading.weight
|
||||||
|
block.dsd = reading.dsd
|
||||||
|
block.mode = reading.mode
|
||||||
|
block.manualNumber = reading.manualNumber ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
|
||||||
|
function counterpartyPayload(): Record<string, unknown> {
|
||||||
|
switch (counterpartyType.value) {
|
||||||
|
case 'CLIENT': return { client: clientIri.value }
|
||||||
|
case 'FOURNISSEUR': return { supplier: supplierIri.value }
|
||||||
|
case 'AUTRE': return { otherLabel: otherLabel.value || null }
|
||||||
|
default: return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
|
||||||
|
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
|
||||||
|
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
|
||||||
|
* pour que `useFormErrors` mappe les 422 inline.
|
||||||
|
*/
|
||||||
|
function buildCreatePayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
counterpartyType: counterpartyType.value,
|
||||||
|
...counterpartyPayload(),
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
emptyDate: empty.date || null,
|
||||||
|
emptyWeight: empty.weight,
|
||||||
|
emptyDsd: empty.dsd,
|
||||||
|
emptyMode: empty.mode,
|
||||||
|
emptyManualNumber: empty.manualNumber || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
immatriculation: immatriculation.value || null,
|
||||||
|
plateFreeFormat: plateFreeFormat.value,
|
||||||
|
fullDate: full.date || null,
|
||||||
|
fullWeight: full.weight,
|
||||||
|
fullDsd: full.dsd,
|
||||||
|
fullMode: full.mode,
|
||||||
|
fullManualNumber: full.manualNumber || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// contrepartie
|
||||||
|
counterpartyType,
|
||||||
|
counterpartyField,
|
||||||
|
clientIri,
|
||||||
|
supplierIri,
|
||||||
|
otherLabel,
|
||||||
|
setCounterpartyType,
|
||||||
|
// véhicule partagé
|
||||||
|
immatriculation,
|
||||||
|
plateFreeFormat,
|
||||||
|
// pesées
|
||||||
|
empty,
|
||||||
|
full,
|
||||||
|
applyReading,
|
||||||
|
// workflow
|
||||||
|
ticketId,
|
||||||
|
buildCreatePayload,
|
||||||
|
buildFullPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
|
||||||
|
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
|
||||||
|
*
|
||||||
|
* Collections récupérées en entier via l'échappatoire `?pagination=false`
|
||||||
|
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
|
||||||
|
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
|
||||||
|
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
|
||||||
|
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
|
||||||
|
*
|
||||||
|
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option au format attendu par MalioSelect ({ label, value }). */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartyMember {
|
||||||
|
'@id': string
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useWeighingTicketReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const clients = ref<RefOption[]>([])
|
||||||
|
const suppliers = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||||
|
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||||
|
const res = await api.get<{ member?: PartyMember[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||||
|
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||||
|
* faire échouer l'autre).
|
||||||
|
*/
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/clients').then((list) => {
|
||||||
|
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}),
|
||||||
|
fetchAll('/suppliers').then((list) => {
|
||||||
|
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clients, suppliers, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||||
|
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||||
|
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||||
|
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||||
|
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketParty {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||||
|
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||||
|
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||||
|
*
|
||||||
|
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||||
|
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||||
|
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||||
|
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||||
|
*/
|
||||||
|
export interface WeighingTicket {
|
||||||
|
id: number
|
||||||
|
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
|
||||||
|
number: string
|
||||||
|
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||||
|
client: WeighingTicketParty | null
|
||||||
|
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||||
|
supplier: WeighingTicketParty | null
|
||||||
|
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||||
|
otherLabel: string | null
|
||||||
|
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||||
|
displayDate: string | null
|
||||||
|
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||||
|
netWeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||||
|
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||||
|
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||||
|
* front n'a pas a envoyer le site.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketFilters {
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||||
|
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||||
|
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||||
|
*
|
||||||
|
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||||
|
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||||
|
*/
|
||||||
|
export function useWeighingTicketsRepository() {
|
||||||
|
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({ url: '/weighing_tickets' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||||
|
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
|
||||||
|
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
|
||||||
|
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||||
|
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||||
|
items: ref([
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||||
|
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||||
|
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||||
|
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||||
|
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||||
|
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => {
|
||||||
|
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||||
|
return h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||||
|
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||||
|
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||||
|
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||||
|
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
return mount(WeighingTicketsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
capturedRows.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||||
|
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('logistique.weighingTickets.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('logistique.weighingTickets.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||||
|
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||||
|
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||||
|
(regle ABSOLUE n°6). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('logistique.weighingTickets.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.title') })
|
||||||
|
|
||||||
|
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||||
|
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||||
|
// back) — spec-front § Acces.
|
||||||
|
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||||
|
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: tickets,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadTickets,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||||
|
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||||
|
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||||
|
const rows = computed(() => tickets.value.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
number: ticket.number,
|
||||||
|
client: ticket.client?.companyName ?? '',
|
||||||
|
supplier: ticket.supplier?.companyName ?? '',
|
||||||
|
otherLabel: ticket.otherLabel ?? '',
|
||||||
|
displayDate: formatDateFr(ticket.displayDate),
|
||||||
|
netWeight: formatWeight(ticket.netWeight),
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||||
|
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||||
|
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||||
|
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||||
|
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||||
|
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Format court francais JJ-MM-AAAA (spec M5). Chaine vide si date absente / invalide. */
|
||||||
|
function formatDateFr(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${day}-${month}-${date.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poids net affiche en kg avec separateur de milliers (espace) + suffixe « Kg »
|
||||||
|
* (spec-front § formatage : « 7 150 Kg »). Chaine vide si poids absent (ticket
|
||||||
|
* dont la pesee a plein n'est pas encore finalisee). Groupement manuel (espace
|
||||||
|
* ASCII) pour un rendu deterministe, independant de l'ICU de l'environnement.
|
||||||
|
*/
|
||||||
|
function formatWeight(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
return `${grouped} Kg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/weighing-tickets/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportXlsx(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
|
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3/M4).
|
||||||
|
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('logistique.weighingTickets.toast.error'),
|
||||||
|
message: t('logistique.weighingTickets.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTickets)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tête : retour vers la liste + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('logistique.weighingTickets.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-[48px] flex flex-col gap-8">
|
||||||
|
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──────── -->
|
||||||
|
<WeighingBlock
|
||||||
|
block-id="empty"
|
||||||
|
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||||
|
:block="form.empty"
|
||||||
|
:immatriculation="form.immatriculation.value"
|
||||||
|
:plate-free-format="form.plateFreeFormat.value"
|
||||||
|
: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
|
||||||
|
:model-value="form.counterpartyType.value"
|
||||||
|
:options="counterpartyOptions"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="emptyLocked"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.counterpartyType"
|
||||||
|
@update:model-value="onCounterpartyTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="form.counterpartyField.value === 'supplier'"
|
||||||
|
:model-value="form.supplierIri.value"
|
||||||
|
:options="referentials.suppliers.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="emptyLocked"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.supplier"
|
||||||
|
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-else-if="form.counterpartyField.value === 'client'"
|
||||||
|
:model-value="form.clientIri.value"
|
||||||
|
:options="referentials.clients.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="emptyLocked"
|
||||||
|
empty-option-label=""
|
||||||
|
:error="errors.client"
|
||||||
|
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else-if="form.counterpartyField.value === 'other'"
|
||||||
|
:model-value="form.otherLabel.value"
|
||||||
|
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||||
|
:required="true"
|
||||||
|
:disabled="emptyLocked"
|
||||||
|
:error="errors.otherLabel"
|
||||||
|
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</WeighingBlock>
|
||||||
|
|
||||||
|
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une
|
||||||
|
fois le ticket créé — RG-5.08). -->
|
||||||
|
<div v-if="form.ticketId.value === null" class="flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.save')"
|
||||||
|
:disabled="creating"
|
||||||
|
@click="submitCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────-->
|
||||||
|
<WeighingBlock
|
||||||
|
block-id="full"
|
||||||
|
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||||
|
:block="form.full"
|
||||||
|
:immatriculation="form.immatriculation.value"
|
||||||
|
:plate-free-format="form.plateFreeFormat.value"
|
||||||
|
:errors="fullBlockErrors"
|
||||||
|
@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-manual="openManual('full')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du
|
||||||
|
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. -->
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.form.validate')"
|
||||||
|
:disabled="validating || form.ticketId.value === null"
|
||||||
|
@click="submitValidate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||||
|
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
|
||||||
|
<!-- Erreur de pont indisponible affichée INLINE dans la modal + invite
|
||||||
|
à la pesée manuelle (RG-5.06). -->
|
||||||
|
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
|
||||||
|
@click="autoModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||||
|
:disabled="autoModal.loading"
|
||||||
|
@click="confirmAuto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
|
||||||
|
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||||
|
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<MalioInputNumber
|
||||||
|
v-model="manualModal.weight"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||||
|
:required="true"
|
||||||
|
:min="0"
|
||||||
|
:error="manualModal.errors.weight"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="manualModal.manualNumber"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.number')"
|
||||||
|
:required="true"
|
||||||
|
:error="manualModal.errors.manualNumber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.cancel')"
|
||||||
|
@click="manualModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||||
|
:disabled="manualModal.loading"
|
||||||
|
@click="confirmManual"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||||
|
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||||
|
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
|
||||||
|
|
||||||
|
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
|
||||||
|
if (!can('logistique.weighing_tickets.manage')) {
|
||||||
|
await navigateTo('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = useWeighingTicketForm()
|
||||||
|
const weighbridge = useWeighbridge()
|
||||||
|
const referentials = useWeighingTicketReferentials()
|
||||||
|
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
/** Retour vers la liste (flèche d'en-tête). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
||||||
|
// Ordre maquette : Fournisseur / Client / Autre.
|
||||||
|
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||||
|
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||||
|
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||||
|
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||||
|
form.setCounterpartyType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||||
|
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.emptyDate,
|
||||||
|
weight: errors.emptyWeight,
|
||||||
|
dsd: errors.emptyDsd,
|
||||||
|
immatriculation: errors.immatriculation,
|
||||||
|
}))
|
||||||
|
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||||
|
date: errors.fullDate,
|
||||||
|
weight: errors.fullWeight,
|
||||||
|
dsd: errors.fullDsd,
|
||||||
|
immatriculation: errors.immatriculation,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||||
|
const autoModal = reactive({
|
||||||
|
open: false,
|
||||||
|
error: '',
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAuto(target: 'empty' | 'full'): void {
|
||||||
|
autoModal.target = target
|
||||||
|
autoModal.error = ''
|
||||||
|
autoModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */
|
||||||
|
async function confirmAuto(): Promise<void> {
|
||||||
|
if (autoModal.loading) return
|
||||||
|
autoModal.loading = true
|
||||||
|
autoModal.error = ''
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerAuto()
|
||||||
|
form.applyReading(form[autoModal.target], reading)
|
||||||
|
autoModal.open = false
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Pont indisponible : message inline + invite à la pesée manuelle.
|
||||||
|
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
autoModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||||
|
const manualModal = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
target: 'empty' as 'empty' | 'full',
|
||||||
|
weight: null as string | number | null,
|
||||||
|
manualNumber: null as string | null,
|
||||||
|
errors: {} as Record<string, string>,
|
||||||
|
})
|
||||||
|
|
||||||
|
function openManual(target: 'empty' | 'full'): void {
|
||||||
|
manualModal.target = target
|
||||||
|
manualModal.weight = null
|
||||||
|
manualModal.manualNumber = null
|
||||||
|
manualModal.errors = {}
|
||||||
|
manualModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */
|
||||||
|
async function confirmManual(): Promise<void> {
|
||||||
|
if (manualModal.loading) return
|
||||||
|
manualModal.errors = {}
|
||||||
|
|
||||||
|
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||||
|
const manualNumber = (manualModal.manualNumber ?? '').trim()
|
||||||
|
if (weight === null || Number.isNaN(weight)) {
|
||||||
|
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||||
|
}
|
||||||
|
if (manualNumber === '') {
|
||||||
|
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
|
||||||
|
}
|
||||||
|
if (Object.keys(manualModal.errors).length > 0) return
|
||||||
|
|
||||||
|
manualModal.loading = true
|
||||||
|
try {
|
||||||
|
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||||
|
form.applyReading(form[manualModal.target], reading)
|
||||||
|
manualModal.open = false
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
manualModal.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soumissions ──────────────────────────────────────────────────────────────
|
||||||
|
interface TicketResponse { id: number }
|
||||||
|
|
||||||
|
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
|
||||||
|
async function submitCreate(): Promise<void> {
|
||||||
|
if (creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
form.ticketId.value = created.id
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
|
||||||
|
async function submitValidate(): Promise<void> {
|
||||||
|
if (validating.value || form.ticketId.value === null) return
|
||||||
|
validating.value = true
|
||||||
|
clearErrors()
|
||||||
|
try {
|
||||||
|
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
|
||||||
|
// 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).
|
||||||
|
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||||
|
router.push('/weighing-tickets')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
validating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Échec du chargement des référentiels non bloquant : les selects restent vides.
|
||||||
|
referentials.load().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user