feat : cause d'entrée bovin + confirmation EDNOTIF asynchrone + historique entrées

- Champ entryCause sur Bovine (enum App\Enum\CauseEntree : Achat/Naissance/PretOuPension)
- Sélecteur "Cause d'entrée" sur le formulaire de saisie (default Achat, required)
- ednotif_confirmed_at sur Bovine : timestamp set par le sync EDNOTIF la première
  fois qu'un bovin remonte dans getInventory. Backfill des bovins existants au
  jour de la migration.
- Inventaire (page + export + stats) filtre les bovins encore "en attente
  EDNOTIF" : ils n'apparaissent qu'une fois confirmés par le sync.
- getter getConfirmedBovineCount sur Reception, exposé en reception:read.
- Tableau Historique full-width sur /entry-exit listant les entrées validées,
  avec filtres de colonnes (numéro, date, fournisseur), compteur Confirmés/Saisis,
  et badge de statut "Confirmée" / "EDNOTIF en attente".
- Tableau récap de l'écran de saisie passé en useDataTableServerState pour
  bénéficier du loading et de la pagination serveur.
- Validation entrée : confirm window obligatoire, message renforcé en cas
  d'écart entre saisis et déclarés.
- Pattern projet "submitted" sur le formulaire d'ajout pour le visuel required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:29:46 +02:00
parent c64e0c7100
commit 476502c91c
13 changed files with 376 additions and 96 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative mb-8">
<div class="flex items-center justify-start gap-6 relative">
<Icon
@click="router.push('/entry-exit')"
name="gg:arrow-left-o"
@@ -9,16 +9,17 @@
/>
<div>
<h1 class="font-bold text-3xl uppercase text-primary-500">
Entrée bovins {{ reception?.identificationNumber ?? `#${receptionId}` }}
Entrée bovins {{ reception?.identificationNumber ?? '' }}
</h1>
<p class="text-sm text-slate-600 mt-1">
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovines.length }}
</p>
</div>
</div>
<p class="text-sm text-slate-600 mt-1 mb-8">
{{ reception?.supplier?.name ?? '—' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
</p>
<form
class="grid grid-cols-4 gap-4 mb-6 items-end"
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
:class="{ submitted }"
@submit.prevent="addBovine"
>
<UiTextInput
@@ -26,59 +27,68 @@
label="Numéro national"
required
/>
<UiNumberInput
v-model="form.receivedWeight"
label="Poids à l'arrivée (kg)"
:min="1"
<UiSelect
v-model="form.entryCause"
label="Cause d'entrée"
:options="entryCauseOptions"
required
/>
<UiDateMaskedInput
v-model="form.arrivalDate"
label="Date d'arrivée"
required
label="Date d'entrée"
/>
<UiSelect
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
required
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix au kilo (€)"
:min="0"
:step="0.01"
required
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
required
/>
<UiSelect
v-model="form.caseId"
label="Case"
:options="caseOptions"
:disabled="!form.buildingId"
required
/>
<UiNumberInput
v-model="form.receivedWeight"
label="Poids (kg)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="1"
/>
<UiNumberInput
v-model="form.pricePerKg"
label="Prix au kilo (€)"
wrapperClass="flex-col"
labelClass="font-bold uppercase text-xl text-primary-700"
:min="0"
:step="0.01"
/>
</form>
<div class="flex justify-center mb-12">
<UiButton
type="submit"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px]"
:disabled="!isFormValid || isAdding"
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="isAdding"
:loading="isAdding"
@click="addBovine"
>
Ajouter
</UiButton>
</form>
</div>
<UiDataTable
v-model:page="recapPage"
v-model:per-page="recapPerPage"
:columns="recapColumns"
:items="savedBovines"
:total-items="savedBovines.length"
:total-items="savedBovinesTotal"
:loading="savedBovinesLoading"
:show-actions="true"
>
<template #cell-birthDate="{ item }">
@@ -112,11 +122,11 @@
</template>
</UiDataTable>
<div class="flex justify-end mt-8 mb-16">
<div class="flex justify-center mt-8">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
:disabled="savedBovines.length === 0 || isValidating"
:disabled="savedBovinesTotal === 0 || isValidating"
:loading="isValidating"
@click="validateEntry"
>
@@ -133,6 +143,7 @@ import type { SupplierData } from '~/services/dto/supplier-data'
import type { BuildingData } from '~/services/dto/building-data'
import { getSupplierList } from '~/services/supplier'
import { getBuildingList } from '~/services/building'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const route = useRoute()
const router = useRouter()
@@ -143,12 +154,23 @@ const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const suppliers = ref<SupplierData[]>([])
const buildings = ref<BuildingData[]>([])
const savedBovines = ref<BovineData[]>([])
const {
items: savedBovines,
totalItems: savedBovinesTotal,
page: recapPage,
perPage: recapPerPage,
loading: savedBovinesLoading,
reload: reloadSavedBovines
} = useDataTableServerState<BovineData>(
'bovines',
{ reception: receptionId.value },
{ initialPerPage: 50 }
)
const isAdding = ref(false)
const isValidating = ref(false)
const recapPage = ref(1)
const recapPerPage = ref(50)
const submitted = ref(false)
const recapColumns = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
@@ -181,18 +203,17 @@ const confirmDeleteBovine = async (bovine: BovineData) => {
if (!confirmed) return
await api.delete(`bovines/${bovine.id}`)
await loadSavedBovines()
reloadSavedBovines()
}
const validateEntry = async () => {
if (savedBovines.value.length === 0 || isValidating.value) return
if (savedBovinesTotal.value === 0 || isValidating.value) return
if (savedBovines.value.length < declaredCount.value) {
const confirmed = window.confirm(
`Vous n'avez saisi que ${savedBovines.value.length}/${declaredCount.value} bovins. Confirmer la fermeture de l'entrée ?`
)
if (!confirmed) return
}
const message = savedBovinesTotal.value !== declaredCount.value
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
: `Êtes-vous sûr de vouloir valider l'entrée ?`
if (!window.confirm(message)) return
isValidating.value = true
try {
@@ -203,24 +224,34 @@ const validateEntry = async () => {
}
}
type EntryCause = 'A' | 'N' | 'P'
interface FormState {
nationalNumber: string
receivedWeight: number | null
entryCause: EntryCause
arrivalDate: string
supplierId: string | number | null
pricePerKg: number | null
buildingId: string | number | null
caseId: string | number | null
receivedWeight: number | null
pricePerKg: number | null
}
const entryCauseOptions = [
{ value: 'A', label: 'Achat' },
{ value: 'N', label: 'Naissance' },
{ value: 'P', label: 'Prêt ou pension' }
]
const initialForm = (): FormState => ({
nationalNumber: '',
receivedWeight: null,
entryCause: 'A',
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
supplierId: reception.value?.supplier?.id ?? null,
pricePerKg: null,
buildingId: reception.value?.buildings?.[0]?.id ?? null,
caseId: null
caseId: null,
receivedWeight: null,
pricePerKg: null
})
const form = reactive<FormState>(initialForm())
@@ -248,23 +279,11 @@ watch(() => form.buildingId, (newVal, oldVal) => {
if (newVal !== oldVal) form.caseId = null
})
const declaredCount = computed(() => {
if (!reception.value) return 0
const fromTypes = (reception.value.bovinesTypes ?? []).reduce((sum: number, bt: any) => {
return sum + (typeof bt.quantity === 'number' ? bt.quantity : 0)
}, 0)
const fromOther = parseInt(reception.value.bovineDetail ?? '0', 10) || 0
return fromTypes + fromOther
})
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
const isFormValid = computed(() =>
form.nationalNumber.trim() !== ''
&& (form.receivedWeight ?? 0) > 0
&& (form.pricePerKg ?? 0) > 0
&& form.arrivalDate !== ''
&& form.supplierId !== null
&& form.buildingId !== null
&& form.caseId !== null
&& !!form.entryCause
)
const resetForm = () => {
@@ -276,43 +295,35 @@ const loadReception = async () => {
resetForm()
}
const loadSavedBovines = async () => {
const response = await api.get<{ 'hydra:member'?: BovineData[] } | BovineData[]>(
`bovines?reception=${receptionId.value}`,
{},
{ toast: false }
)
savedBovines.value = Array.isArray(response)
? response
: (response['hydra:member'] ?? [])
}
const focusFirstField = () => {
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
el?.focus()
}
const addBovine = async () => {
submitted.value = true
if (!isFormValid.value || isAdding.value) return
isAdding.value = true
try {
const payload = {
const payload: Record<string, unknown> = {
nationalNumber: form.nationalNumber.trim(),
receivedWeight: form.receivedWeight,
pricePerKg: form.pricePerKg,
arrivalDate: form.arrivalDate,
supplier: `/api/suppliers/${form.supplierId}`,
buildingCase: `/api/building_cases/${form.caseId}`,
entryCause: form.entryCause,
reception: `/api/receptions/${receptionId.value}`
}
if (form.receivedWeight !== null) payload.receivedWeight = form.receivedWeight
if (form.pricePerKg !== null) payload.pricePerKg = form.pricePerKg
if (form.arrivalDate) payload.arrivalDate = form.arrivalDate
if (form.supplierId !== null) payload.supplier = `/api/suppliers/${form.supplierId}`
if (form.caseId !== null) payload.buildingCase = `/api/building_cases/${form.caseId}`
await api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' }
})
await loadSavedBovines()
reloadSavedBovines()
resetForm()
submitted.value = false
await nextTick()
focusFirstField()
} finally {
@@ -326,6 +337,6 @@ onMounted(async () => {
getBuildingList()
])
await loadReception()
await loadSavedBovines()
reloadSavedBovines()
})
</script>

View File

@@ -10,7 +10,7 @@
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
</div>
<div class="mt-8 mb-16 grid grid-cols-2 gap-8">
<div class="mt-8 grid grid-cols-2 gap-8">
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
<UiDataTable
@@ -23,11 +23,14 @@
row-clickable
@row-click="goToEntry"
>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-declaredCount="{ item }">
{{ declaredCount(item) }}
{{ item.declaredBovineCount ?? 0 }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
@@ -42,6 +45,71 @@
</div>
</section>
</div>
<section class="mt-12 mb-16">
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Historique</h2>
<UiDataTable
v-model:page="historyPage"
v-model:per-page="historyPerPage"
:columns="historyColumns"
:items="history"
:total-items="totalHistory"
:loading="historyLoading"
>
<template #header-identificationNumber>
<UiTextInput
v-model="historyFilters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateMaskedInput v-model="historyDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput
v-model="historyFilters['supplier.name']"
placeholder="Fournisseur"
size="compact"
/>
</template>
<template #header-registeredBovineCount>
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
</template>
<template #header-confirmedBovineCount>
<UiTextInput :model-value="''" placeholder="Confirmés" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
</template>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-confirmedBovineCount="{ item }">
{{ item.confirmedBovineCount ?? 0 }} / {{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-status="{ item }">
<span
v-if="(item.confirmedBovineCount ?? 0) >= (item.registeredBovineCount ?? 0) && (item.registeredBovineCount ?? 0) > 0"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Confirmée
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
EDNOTIF en attente
</span>
</template>
</UiDataTable>
</section>
</div>
</template>
@@ -65,23 +133,66 @@ const {
'entryCompleted': 'false',
'receptionType.code': 'BOVINS'
},
{ initialPerPage: 5 }
)
const {
items: history,
totalItems: totalHistory,
page: historyPage,
perPage: historyPerPage,
filters: historyFilters,
loading: historyLoading,
reload: reloadHistory
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'entryCompleted': 'true',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'supplier.name': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const historyDateFilter = computed<string>({
get: () => (historyFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
historyFilters.value['receptionDate[after]'] = ''
historyFilters.value['receptionDate[strictly_before]'] = ''
return
}
historyFilters.value['receptionDate[after]'] = value
historyFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const entryColumns = [
{ key: 'receptionDate', label: 'Date réception', width: '160px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'declaredCount', label: 'Bovins déclarés', width: '140px' },
{ key: 'registeredBovineCount', label: 'Bovins saisis', width: '140px' }
{ key: 'identificationNumber', label: 'Numéro', width: '80px' },
{ key: 'receptionDate', label: 'Date', width: '75px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
{ key: 'declaredCount', label: 'Déclarés', width: '85px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' }
]
const declaredCount = (reception: ReceptionData): number => {
const fromTypes = (reception.bovinesTypes ?? []).reduce((sum: number, bt: any) => {
return sum + (typeof bt.quantity === 'number' ? bt.quantity : 0)
}, 0)
const fromOther = parseInt(reception.bovineDetail ?? '0', 10) || 0
return fromTypes + fromOther
}
const historyColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '110px' },
{ key: 'receptionDate', label: 'Date', width: '110px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '80px' },
{ key: 'confirmedBovineCount', label: 'Confirmés', width: '110px' },
{ key: 'status', label: 'Statut', width: '170px' }
]
const formatDate = (date: string | null) => {
if (!date) return '—'
@@ -90,9 +201,7 @@ const formatDate = (date: string | null) => {
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
year: 'numeric'
})
}
@@ -102,5 +211,6 @@ const goToEntry = (reception: ReceptionData) => {
onMounted(() => {
reload()
reloadHistory()
})
</script>

View File

@@ -247,6 +247,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
'bovines',
{
'exists[exitedAt]': 'false',
'exists[ednotifConfirmedAt]': 'true',
nationalNumber: '',
workNumber: '',
'bovineType.label': '',