feat : M5 — Tickets de pesée (ERP-188 → ERP-193) (#144)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
MR unique regroupant tout le module M5 « Tickets de pesée » (remplace les MR empilées #140/#141/#142/#143).
## Périmètre
- **ERP-188** — Page liste des tickets de pesée + export XLSX (colonnes Fournisseur/Client/Autre + Statut).
- **ERP-189** — Écran « Ajouter » (4 champs en haut, 2 blocs de pesée, pesée bascule/manuelle, date+heure horodatée à la validation).
- **ERP-190** — Écran « Modifier » + bouton Imprimer.
- **ERP-191** — i18n + libellés + branchement site courant.
- **ERP-192** — Bon de pesée PDF généré côté back (template Twig → Dompdf), endpoint `GET /api/weighing_tickets/{id}/print.pdf`.
- **ERP-193** — Cycle de vie brouillon/validé (status DRAFT/VALIDATED, numéro attribué à la validation), DSD saisi conservé en pesée manuelle, retours métier design.
## Vérifications
- Back : tests Logistique + architecture verts, php-cs-fixer propre, migrations appliquées (dev + test).
- Front : suite Vitest complète verte, ESLint propre.
Base : `develop` — contient les 16 commits du M5 (rien d'autre).
Reviewed-on: #144
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #144.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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é pour vérifier le
|
||||
// pré-remplissage via hydrate). ─────────────────────────────────────────────
|
||||
const mockFetchTicket = 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/useWeighingTicket', () => ({
|
||||
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
||||
}))
|
||||
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 stubbes globalement ───────────────────────────────────
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
|
||||
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
|
||||
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 EditPage = (await import('../weighing-tickets/[id]/edit.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)
|
||||
},
|
||||
})
|
||||
|
||||
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 })
|
||||
},
|
||||
})
|
||||
|
||||
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
|
||||
// dans les 4 champs du haut, hors bloc (ERP-193).
|
||||
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,
|
||||
MalioInputNumber: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioDateTime: InputStub,
|
||||
MalioCheckbox: InputStub,
|
||||
MalioModal: ModalStub,
|
||||
WeighingBlock: BlockStub,
|
||||
}
|
||||
|
||||
// Monte la page (setup async : top-level await) via Suspense.
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { EditPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const DETAIL = {
|
||||
id: 9,
|
||||
status: 'VALIDATED',
|
||||
number: '86-TP-0001',
|
||||
site: { id: 1, name: 'Chatellerault', code: '86' },
|
||||
counterpartyType: 'CLIENT',
|
||||
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||
immatriculation: 'AB-123-CD',
|
||||
plateFreeFormat: false,
|
||||
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||
}
|
||||
|
||||
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
|
||||
beforeEach(() => {
|
||||
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
|
||||
mockPatch.mockReset().mockResolvedValue({})
|
||||
mockPush.mockReset()
|
||||
mockOpen.mockReset()
|
||||
})
|
||||
|
||||
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
|
||||
await mountPage()
|
||||
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||
})
|
||||
|
||||
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
|
||||
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.print"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
|
||||
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||
})
|
||||
|
||||
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
||||
await flushPromises()
|
||||
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/weighing_tickets/9',
|
||||
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } 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 mockReset = 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 }))
|
||||
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
|
||||
const currentSiteRef = ref<{ id: number } | null>(null)
|
||||
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
|
||||
|
||||
// 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(),
|
||||
reset: mockReset,
|
||||
}))
|
||||
|
||||
// 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?.()]) },
|
||||
})
|
||||
|
||||
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
|
||||
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
|
||||
// d'un test à l'autre et se déclencheraient en double.
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
function mountPage() {
|
||||
const wrapper = mount(WeighingTicketsIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockFetch.mockReset()
|
||||
mockReset.mockReset()
|
||||
mockToastError.mockReset()
|
||||
capturedRows.value = []
|
||||
currentSiteRef.value = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Démonte les composants montés → libère leurs watchers (site courant).
|
||||
while (mountedWrappers.length > 0) {
|
||||
mountedWrappers.pop()?.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recharge la liste (page 1) quand le site courant change', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
|
||||
// Simule un switch de site via le switcher global.
|
||||
currentSiteRef.value = { id: 2 }
|
||||
await flushPromises()
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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,421 @@
|
||||
<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">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- États de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||
sépare chacun des 3 blocs (divide-y). -->
|
||||
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||
<!-- ── 4 champs du haut : contrepartie + immatriculation + « Tout
|
||||
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
|
||||
padding-top (marge titre→form = mt-[48px] standard). ───────── -->
|
||||
<div class="pb-[20px]">
|
||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioSelect
|
||||
:model-value="form.counterpartyType.value"
|
||||
:options="counterpartyOptions"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||
:required="true"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
:error="errors.otherLabel"
|
||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||
/>
|
||||
|
||||
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
|
||||
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
|
||||
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"
|
||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||
:block="form.full"
|
||||
:errors="fullBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||
@request-auto="openAuto('full')"
|
||||
@request-manual="openManual('full')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
|
||||
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
|
||||
<div class="mt-12 flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:printer-outline"
|
||||
icon-position="left"
|
||||
:label="t('logistique.weighingTickets.form.print')"
|
||||
@click="printTicket"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="primaryLabel"
|
||||
:disabled="saving"
|
||||
@click="submitPrimary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||
</template>
|
||||
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
: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"
|
||||
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||
body-class="px-7 pt-9"
|
||||
footer-class="px-7 justify-center pb-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="manualModal.weight"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.weight"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="manualModal.dsd"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.dsd"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
: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 { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
|
||||
if (!can('logistique.weighing_tickets.manage')) {
|
||||
await navigateTo('/weighing-tickets')
|
||||
}
|
||||
|
||||
const ticketId = route.params.id as string
|
||||
|
||||
const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { fetchTicket } = useWeighingTicket()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
|
||||
const ticketNumber = ref<string>('')
|
||||
|
||||
const headerTitle = computed(() =>
|
||||
ticketNumber.value
|
||||
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
|
||||
: 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') })
|
||||
|
||||
/** 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,
|
||||
}))
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
}))
|
||||
|
||||
/** 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 {
|
||||
(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 puis enregistre le brouillon (ERP-193). */
|
||||
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
|
||||
await saveDraft()
|
||||
}
|
||||
catch (e) {
|
||||
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
||||
}
|
||||
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 | null,
|
||||
dsd: null as string | null,
|
||||
errors: {} as Record<string, string>,
|
||||
})
|
||||
|
||||
function openManual(target: 'empty' | 'full'): void {
|
||||
manualModal.target = target
|
||||
manualModal.weight = null
|
||||
manualModal.dsd = null
|
||||
manualModal.errors = {}
|
||||
manualModal.open = true
|
||||
}
|
||||
|
||||
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||
async function confirmManual(): Promise<void> {
|
||||
if (manualModal.loading) return
|
||||
manualModal.errors = {}
|
||||
|
||||
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||
if (weight === null || Number.isNaN(weight)) {
|
||||
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||
}
|
||||
if (dsd === null || Number.isNaN(dsd)) {
|
||||
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||
}
|
||||
if (Object.keys(manualModal.errors).length > 0) return
|
||||
|
||||
manualModal.loading = true
|
||||
try {
|
||||
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||
form.applyReading(form[manualModal.target], reading)
|
||||
manualModal.open = false
|
||||
await saveDraft()
|
||||
}
|
||||
catch (e) {
|
||||
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||
const violations = mapViolationsToRecord((e as { response?: { _data?: unknown } })?.response?._data)
|
||||
manualModal.errors = Object.keys(violations).length > 0
|
||||
? violations
|
||||
: { weight: weighbridge.extractWeighbridgeError(e) }
|
||||
}
|
||||
finally {
|
||||
manualModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistance / impression ──────────────────────────────────────────────────
|
||||
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
|
||||
async function saveDraft(): Promise<boolean> {
|
||||
clearErrors()
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
|
||||
return true
|
||||
}
|
||||
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
|
||||
try {
|
||||
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')
|
||||
}
|
||||
catch (e) {
|
||||
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
|
||||
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
|
||||
*/
|
||||
function printTicket(): void {
|
||||
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referentials.load().catch(() => {})
|
||||
try {
|
||||
const detail = await fetchTicket(ticketId)
|
||||
ticketNumber.value = detail.number ?? ''
|
||||
form.hydrate(detail)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<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, watch } from 'vue'
|
||||
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
// Site courant (switcher global) : la liste est cloisonnée par site côté back
|
||||
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
|
||||
// contente de recharger quand le site change pour refléter le bon périmètre.
|
||||
const { currentSite } = useCurrentSite()
|
||||
|
||||
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,
|
||||
reset: reloadFromFirstPage,
|
||||
} = 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,
|
||||
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
|
||||
number: ticket.number ?? '',
|
||||
client: ticket.client?.companyName ?? '',
|
||||
supplier: ticket.supplier?.companyName ?? '',
|
||||
otherLabel: ticket.otherLabel ?? '',
|
||||
displayDate: formatDateFr(ticket.displayDate),
|
||||
netWeight: formatWeightKg(ticket.netWeight),
|
||||
status: t(ticket.status === 'VALIDATED'
|
||||
? 'logistique.weighingTickets.status.validated'
|
||||
: 'logistique.weighingTickets.status.draft'),
|
||||
})))
|
||||
|
||||
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') },
|
||||
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
|
||||
]
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
|
||||
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
|
||||
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
|
||||
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
|
||||
// changement.
|
||||
watch(() => currentSite.value?.id, (id, previousId) => {
|
||||
if (id !== previousId) {
|
||||
reloadFromFirstPage()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadTickets)
|
||||
</script>
|
||||
@@ -0,0 +1,381 @@
|
||||
<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>
|
||||
|
||||
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
|
||||
sépare chacun des 3 blocs (divide-y). -->
|
||||
<div class="mt-[48px] flex flex-col divide-y divide-black">
|
||||
<!-- ── 4 champs du haut : contrepartie (type + champ conditionnel),
|
||||
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
|
||||
1er bloc : pas de padding-top (marge titre→form = mt-[48px] standard). ── -->
|
||||
<div class="pb-[20px]">
|
||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioSelect
|
||||
:model-value="form.counterpartyType.value"
|
||||
:options="counterpartyOptions"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||
:required="true"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
:error="errors.otherLabel"
|
||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||
/>
|
||||
|
||||
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
|
||||
« Tout format » se collent au type, et le champ conditionnel
|
||||
les décale une fois un type sélectionné. -->
|
||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
|
||||
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
|
||||
<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
|
||||
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"
|
||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||
:block="form.full"
|
||||
:errors="fullBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||
@request-auto="openAuto('full')"
|
||||
@request-manual="openManual('full')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
|
||||
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">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.validate')"
|
||||
:disabled="validating"
|
||||
@click="submitValidate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||
</template>
|
||||
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
: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"
|
||||
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
|
||||
body-class="px-7 pt-9"
|
||||
footer-class="px-7 justify-center pb-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="manualModal.weight"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.weight"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="manualModal.dsd"
|
||||
:mask="NUMERIC_MASK"
|
||||
:label="t('logistique.weighingTickets.form.manual.dsd')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.dsd"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
: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'
|
||||
import { NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
}))
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
}))
|
||||
|
||||
/** 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 {
|
||||
(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 puis enregistre le brouillon (ERP-193). */
|
||||
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
|
||||
await saveDraft()
|
||||
}
|
||||
catch (error) {
|
||||
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 | null,
|
||||
dsd: null as string | null,
|
||||
errors: {} as Record<string, string>,
|
||||
})
|
||||
|
||||
function openManual(target: 'empty' | 'full'): void {
|
||||
manualModal.target = target
|
||||
manualModal.weight = null
|
||||
manualModal.dsd = null
|
||||
manualModal.errors = {}
|
||||
manualModal.open = true
|
||||
}
|
||||
|
||||
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
|
||||
async function confirmManual(): Promise<void> {
|
||||
if (manualModal.loading) return
|
||||
manualModal.errors = {}
|
||||
|
||||
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
|
||||
if (weight === null || Number.isNaN(weight)) {
|
||||
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||
}
|
||||
if (dsd === null || Number.isNaN(dsd)) {
|
||||
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
|
||||
}
|
||||
if (Object.keys(manualModal.errors).length > 0) return
|
||||
|
||||
manualModal.loading = true
|
||||
try {
|
||||
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
|
||||
form.applyReading(form[manualModal.target], reading)
|
||||
manualModal.open = false
|
||||
await saveDraft()
|
||||
}
|
||||
catch (error) {
|
||||
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
|
||||
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
|
||||
// (503 pont indispo, réseau) → message générique sous le champ Poids.
|
||||
const violations = mapViolationsToRecord((error as { response?: { _data?: unknown } })?.response?._data)
|
||||
manualModal.errors = Object.keys(violations).length > 0
|
||||
? violations
|
||||
: { weight: weighbridge.extractWeighbridgeError(error) }
|
||||
}
|
||||
finally {
|
||||
manualModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistance ──────────────────────────────────────────────────────────────
|
||||
interface TicketResponse { id: number }
|
||||
|
||||
/**
|
||||
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
|
||||
* 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()
|
||||
try {
|
||||
if (form.ticketId.value === null) {
|
||||
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
form.ticketId.value = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* « 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> {
|
||||
if (validating.value) return
|
||||
validating.value = true
|
||||
try {
|
||||
if (!(await saveDraft())) return
|
||||
|
||||
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
|
||||
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(() => {
|
||||
referentials.load().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user