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:
@@ -153,7 +153,7 @@ const props = withDefaults(defineProps<{
|
|||||||
totalItems: undefined,
|
totalItems: undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
perPageOptions: () => [10, 25, 50],
|
perPageOptions: () => [5, 10, 25, 50],
|
||||||
rowClickable: false,
|
rowClickable: false,
|
||||||
showActions: false,
|
showActions: false,
|
||||||
emptyMessage: 'Aucune donnée',
|
emptyMessage: 'Aucune donnée',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-[86px]">
|
<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
|
<Icon
|
||||||
@click="router.push('/entry-exit')"
|
@click="router.push('/entry-exit')"
|
||||||
name="gg:arrow-left-o"
|
name="gg:arrow-left-o"
|
||||||
@@ -9,16 +9,17 @@
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
<h1 class="font-bold text-3xl uppercase text-primary-500">
|
||||||
Entrée bovins {{ reception?.identificationNumber ?? `#${receptionId}` }}
|
Entrée bovins {{ reception?.identificationNumber ?? '' }}
|
||||||
</h1>
|
</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>
|
||||||
</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
|
<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"
|
@submit.prevent="addBovine"
|
||||||
>
|
>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
@@ -26,59 +27,68 @@
|
|||||||
label="Numéro national"
|
label="Numéro national"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<UiNumberInput
|
<UiSelect
|
||||||
v-model="form.receivedWeight"
|
v-model="form.entryCause"
|
||||||
label="Poids à l'arrivée (kg)"
|
label="Cause d'entrée"
|
||||||
:min="1"
|
:options="entryCauseOptions"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<UiDateMaskedInput
|
<UiDateMaskedInput
|
||||||
v-model="form.arrivalDate"
|
v-model="form.arrivalDate"
|
||||||
label="Date d'arrivée"
|
label="Date d'entrée"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-model="form.supplierId"
|
v-model="form.supplierId"
|
||||||
label="Vendeur"
|
label="Vendeur"
|
||||||
:options="supplierOptions"
|
:options="supplierOptions"
|
||||||
required
|
|
||||||
/>
|
|
||||||
<UiNumberInput
|
|
||||||
v-model="form.pricePerKg"
|
|
||||||
label="Prix au kilo (€)"
|
|
||||||
:min="0"
|
|
||||||
:step="0.01"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-model="form.buildingId"
|
v-model="form.buildingId"
|
||||||
label="Bâtiment"
|
label="Bâtiment"
|
||||||
:options="buildingOptions"
|
:options="buildingOptions"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<UiSelect
|
<UiSelect
|
||||||
v-model="form.caseId"
|
v-model="form.caseId"
|
||||||
label="Case"
|
label="Case"
|
||||||
:options="caseOptions"
|
:options="caseOptions"
|
||||||
:disabled="!form.buildingId"
|
: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
|
<UiButton
|
||||||
type="submit"
|
type="button"
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px]"
|
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
||||||
:disabled="!isFormValid || isAdding"
|
:disabled="isAdding"
|
||||||
:loading="isAdding"
|
:loading="isAdding"
|
||||||
|
@click="addBovine"
|
||||||
>
|
>
|
||||||
Ajouter
|
Ajouter
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<UiDataTable
|
<UiDataTable
|
||||||
v-model:page="recapPage"
|
v-model:page="recapPage"
|
||||||
v-model:per-page="recapPerPage"
|
v-model:per-page="recapPerPage"
|
||||||
:columns="recapColumns"
|
:columns="recapColumns"
|
||||||
:items="savedBovines"
|
:items="savedBovines"
|
||||||
:total-items="savedBovines.length"
|
:total-items="savedBovinesTotal"
|
||||||
|
:loading="savedBovinesLoading"
|
||||||
:show-actions="true"
|
:show-actions="true"
|
||||||
>
|
>
|
||||||
<template #cell-birthDate="{ item }">
|
<template #cell-birthDate="{ item }">
|
||||||
@@ -112,11 +122,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</UiDataTable>
|
</UiDataTable>
|
||||||
|
|
||||||
<div class="flex justify-end mt-8 mb-16">
|
<div class="flex justify-center mt-8">
|
||||||
<UiButton
|
<UiButton
|
||||||
type="button"
|
type="button"
|
||||||
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
|
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"
|
:loading="isValidating"
|
||||||
@click="validateEntry"
|
@click="validateEntry"
|
||||||
>
|
>
|
||||||
@@ -133,6 +143,7 @@ import type { SupplierData } from '~/services/dto/supplier-data'
|
|||||||
import type { BuildingData } from '~/services/dto/building-data'
|
import type { BuildingData } from '~/services/dto/building-data'
|
||||||
import { getSupplierList } from '~/services/supplier'
|
import { getSupplierList } from '~/services/supplier'
|
||||||
import { getBuildingList } from '~/services/building'
|
import { getBuildingList } from '~/services/building'
|
||||||
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -143,12 +154,23 @@ const receptionId = computed(() => Number(route.params.id))
|
|||||||
const reception = ref<ReceptionData | null>(null)
|
const reception = ref<ReceptionData | null>(null)
|
||||||
const suppliers = ref<SupplierData[]>([])
|
const suppliers = ref<SupplierData[]>([])
|
||||||
const buildings = ref<BuildingData[]>([])
|
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 isAdding = ref(false)
|
||||||
const isValidating = ref(false)
|
const isValidating = ref(false)
|
||||||
const recapPage = ref(1)
|
const submitted = ref(false)
|
||||||
const recapPerPage = ref(50)
|
|
||||||
|
|
||||||
const recapColumns = [
|
const recapColumns = [
|
||||||
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
|
||||||
@@ -181,18 +203,17 @@ const confirmDeleteBovine = async (bovine: BovineData) => {
|
|||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
await api.delete(`bovines/${bovine.id}`)
|
await api.delete(`bovines/${bovine.id}`)
|
||||||
await loadSavedBovines()
|
reloadSavedBovines()
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateEntry = async () => {
|
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 message = savedBovinesTotal.value !== declaredCount.value
|
||||||
const confirmed = window.confirm(
|
? `Attention : ${savedBovinesTotal.value} bovins saisis sur ${declaredCount.value} déclarés. Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||||
`Vous n'avez saisi que ${savedBovines.value.length}/${declaredCount.value} bovins. Confirmer la fermeture de l'entrée ?`
|
: `Êtes-vous sûr de vouloir valider l'entrée ?`
|
||||||
)
|
|
||||||
if (!confirmed) return
|
if (!window.confirm(message)) return
|
||||||
}
|
|
||||||
|
|
||||||
isValidating.value = true
|
isValidating.value = true
|
||||||
try {
|
try {
|
||||||
@@ -203,24 +224,34 @@ const validateEntry = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EntryCause = 'A' | 'N' | 'P'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
nationalNumber: string
|
nationalNumber: string
|
||||||
receivedWeight: number | null
|
entryCause: EntryCause
|
||||||
arrivalDate: string
|
arrivalDate: string
|
||||||
supplierId: string | number | null
|
supplierId: string | number | null
|
||||||
pricePerKg: number | null
|
|
||||||
buildingId: string | number | null
|
buildingId: string | number | null
|
||||||
caseId: 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 => ({
|
const initialForm = (): FormState => ({
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
receivedWeight: null,
|
entryCause: 'A',
|
||||||
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
|
||||||
supplierId: reception.value?.supplier?.id ?? null,
|
supplierId: reception.value?.supplier?.id ?? null,
|
||||||
pricePerKg: null,
|
|
||||||
buildingId: reception.value?.buildings?.[0]?.id ?? null,
|
buildingId: reception.value?.buildings?.[0]?.id ?? null,
|
||||||
caseId: null
|
caseId: null,
|
||||||
|
receivedWeight: null,
|
||||||
|
pricePerKg: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive<FormState>(initialForm())
|
const form = reactive<FormState>(initialForm())
|
||||||
@@ -248,23 +279,11 @@ watch(() => form.buildingId, (newVal, oldVal) => {
|
|||||||
if (newVal !== oldVal) form.caseId = null
|
if (newVal !== oldVal) form.caseId = null
|
||||||
})
|
})
|
||||||
|
|
||||||
const declaredCount = computed(() => {
|
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
|
||||||
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 isFormValid = computed(() =>
|
const isFormValid = computed(() =>
|
||||||
form.nationalNumber.trim() !== ''
|
form.nationalNumber.trim() !== ''
|
||||||
&& (form.receivedWeight ?? 0) > 0
|
&& !!form.entryCause
|
||||||
&& (form.pricePerKg ?? 0) > 0
|
|
||||||
&& form.arrivalDate !== ''
|
|
||||||
&& form.supplierId !== null
|
|
||||||
&& form.buildingId !== null
|
|
||||||
&& form.caseId !== null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -276,43 +295,35 @@ const loadReception = async () => {
|
|||||||
resetForm()
|
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 focusFirstField = () => {
|
||||||
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
|
const el = document.querySelector<HTMLInputElement>('form input[type="text"]')
|
||||||
el?.focus()
|
el?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addBovine = async () => {
|
const addBovine = async () => {
|
||||||
|
submitted.value = true
|
||||||
if (!isFormValid.value || isAdding.value) return
|
if (!isFormValid.value || isAdding.value) return
|
||||||
|
|
||||||
isAdding.value = true
|
isAdding.value = true
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
nationalNumber: form.nationalNumber.trim(),
|
nationalNumber: form.nationalNumber.trim(),
|
||||||
receivedWeight: form.receivedWeight,
|
entryCause: form.entryCause,
|
||||||
pricePerKg: form.pricePerKg,
|
|
||||||
arrivalDate: form.arrivalDate,
|
|
||||||
supplier: `/api/suppliers/${form.supplierId}`,
|
|
||||||
buildingCase: `/api/building_cases/${form.caseId}`,
|
|
||||||
reception: `/api/receptions/${receptionId.value}`
|
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, {
|
await api.post<BovineData>('bovines', payload, {
|
||||||
headers: { 'Content-Type': 'application/ld+json' }
|
headers: { 'Content-Type': 'application/ld+json' }
|
||||||
})
|
})
|
||||||
|
|
||||||
await loadSavedBovines()
|
reloadSavedBovines()
|
||||||
resetForm()
|
resetForm()
|
||||||
|
submitted.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
focusFirstField()
|
focusFirstField()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -326,6 +337,6 @@ onMounted(async () => {
|
|||||||
getBuildingList()
|
getBuildingList()
|
||||||
])
|
])
|
||||||
await loadReception()
|
await loadReception()
|
||||||
await loadSavedBovines()
|
reloadSavedBovines()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Entrée / Sortie</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 mb-16 grid grid-cols-2 gap-8">
|
<div class="mt-8 grid grid-cols-2 gap-8">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées en attente</h2>
|
||||||
<UiDataTable
|
<UiDataTable
|
||||||
@@ -23,11 +23,14 @@
|
|||||||
row-clickable
|
row-clickable
|
||||||
@row-click="goToEntry"
|
@row-click="goToEntry"
|
||||||
>
|
>
|
||||||
|
<template #cell-identificationNumber="{ item }">
|
||||||
|
{{ item.identificationNumber }}
|
||||||
|
</template>
|
||||||
<template #cell-receptionDate="{ item }">
|
<template #cell-receptionDate="{ item }">
|
||||||
{{ formatDate(item.receptionDate) }}
|
{{ formatDate(item.receptionDate) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-declaredCount="{ item }">
|
<template #cell-declaredCount="{ item }">
|
||||||
{{ declaredCount(item) }}
|
{{ item.declaredBovineCount ?? 0 }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell-registeredBovineCount="{ item }">
|
<template #cell-registeredBovineCount="{ item }">
|
||||||
{{ item.registeredBovineCount ?? 0 }}
|
{{ item.registeredBovineCount ?? 0 }}
|
||||||
@@ -42,6 +45,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,23 +133,66 @@ const {
|
|||||||
'entryCompleted': 'false',
|
'entryCompleted': 'false',
|
||||||
'receptionType.code': 'BOVINS'
|
'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 }
|
{ 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 = [
|
const entryColumns = [
|
||||||
{ key: 'receptionDate', label: 'Date réception', width: '160px' },
|
{ key: 'identificationNumber', label: 'Numéro', width: '80px' },
|
||||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
{ key: 'receptionDate', label: 'Date', width: '75px' },
|
||||||
{ key: 'declaredCount', label: 'Bovins déclarés', width: '140px' },
|
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
|
||||||
{ key: 'registeredBovineCount', label: 'Bovins saisis', width: '140px' }
|
{ key: 'declaredCount', label: 'Déclarés', width: '85px' },
|
||||||
|
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const declaredCount = (reception: ReceptionData): number => {
|
const historyColumns = [
|
||||||
const fromTypes = (reception.bovinesTypes ?? []).reduce((sum: number, bt: any) => {
|
{ key: 'identificationNumber', label: 'Numéro', width: '110px' },
|
||||||
return sum + (typeof bt.quantity === 'number' ? bt.quantity : 0)
|
{ key: 'receptionDate', label: 'Date', width: '110px' },
|
||||||
}, 0)
|
{ key: 'supplier.name', label: 'Fournisseur', width: '1fr' },
|
||||||
const fromOther = parseInt(reception.bovineDetail ?? '0', 10) || 0
|
{ key: 'registeredBovineCount', label: 'Saisis', width: '80px' },
|
||||||
return fromTypes + fromOther
|
{ key: 'confirmedBovineCount', label: 'Confirmés', width: '110px' },
|
||||||
}
|
{ key: 'status', label: 'Statut', width: '170px' }
|
||||||
|
]
|
||||||
|
|
||||||
const formatDate = (date: string | null) => {
|
const formatDate = (date: string | null) => {
|
||||||
if (!date) return '—'
|
if (!date) return '—'
|
||||||
@@ -90,9 +201,7 @@ const formatDate = (date: string | null) => {
|
|||||||
return d.toLocaleDateString('fr-FR', {
|
return d.toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric'
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,5 +211,6 @@ const goToEntry = (reception: ReceptionData) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
reload()
|
reload()
|
||||||
|
reloadHistory()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ const { items, totalItems, page, perPage, filters, loading, reload } =
|
|||||||
'bovines',
|
'bovines',
|
||||||
{
|
{
|
||||||
'exists[exitedAt]': 'false',
|
'exists[exitedAt]': 'false',
|
||||||
|
'exists[ednotifConfirmedAt]': 'true',
|
||||||
nationalNumber: '',
|
nationalNumber: '',
|
||||||
workNumber: '',
|
workNumber: '',
|
||||||
'bovineType.label': '',
|
'bovineType.label': '',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface BovineData {
|
|||||||
ageMonths: number | null
|
ageMonths: number | null
|
||||||
exitedAt: string | null
|
exitedAt: string | null
|
||||||
reception?: string | null
|
reception?: string | null
|
||||||
|
entryCause?: 'A' | 'N' | 'P' | null
|
||||||
|
ednotifConfirmedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BovinePayload = {
|
export type BovinePayload = {
|
||||||
@@ -36,4 +38,5 @@ export type BovinePayload = {
|
|||||||
buildingCase?: string | null
|
buildingCase?: string | null
|
||||||
supplier?: string | null
|
supplier?: string | null
|
||||||
reception?: string | null
|
reception?: string | null
|
||||||
|
entryCause?: 'A' | 'N' | 'P' | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ReceptionData {
|
|||||||
isValid: boolean
|
isValid: boolean
|
||||||
entryCompleted?: boolean
|
entryCompleted?: boolean
|
||||||
registeredBovineCount?: number
|
registeredBovineCount?: number
|
||||||
|
confirmedBovineCount?: number
|
||||||
|
declaredBovineCount?: number
|
||||||
receptionType?: ReceptionTypeData | null
|
receptionType?: ReceptionTypeData | null
|
||||||
merchandiseType?: MerchandiseTypeData | null
|
merchandiseType?: MerchandiseTypeData | null
|
||||||
merchandiseDetail?: string | null
|
merchandiseDetail?: string | null
|
||||||
|
|||||||
26
migrations/Version20260429101011.php
Normal file
26
migrations/Version20260429101011.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260429101011 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return "Ajout de la cause d'entrée (enum CauseEntree EDNOTIF) sur bovine.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD entry_cause VARCHAR(1) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP entry_cause');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
migrations/Version20260429143822.php
Normal file
32
migrations/Version20260429143822.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260429143822 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Ajout de bovine.ednotif_confirmed_at (timestamp de confirmation EDNOTIF par le sync inventory).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD ednotif_confirmed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
|
||||||
|
// Backfill : les bovins déjà en BDD ont été synchronisés depuis EDNOTIF
|
||||||
|
// historiquement (commande sync-inventory), on les considère confirmés.
|
||||||
|
// Les nouveaux bovins créés via le workflow entrée auront `NULL` par
|
||||||
|
// défaut et seront confirmés au prochain sync.
|
||||||
|
$this->addSql('UPDATE bovine SET ednotif_confirmed_at = NOW() WHERE ednotif_confirmed_at IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP ednotif_confirmed_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Enum\CauseEntree;
|
||||||
use App\Repository\BovineRepository;
|
use App\Repository\BovineRepository;
|
||||||
use App\State\Bovin\BovineProcessor;
|
use App\State\Bovin\BovineProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -38,7 +39,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
'reception' => 'exact',
|
'reception' => 'exact',
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt', 'ednotifConfirmedAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
order: ['birthDate' => 'ASC'],
|
order: ['birthDate' => 'ASC'],
|
||||||
operations: [
|
operations: [
|
||||||
@@ -106,6 +107,10 @@ class Bovine
|
|||||||
#[ApiProperty(readableLink: false)]
|
#[ApiProperty(readableLink: false)]
|
||||||
private ?Reception $reception = null;
|
private ?Reception $reception = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 1, nullable: true, enumType: CauseEntree::class)]
|
||||||
|
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
|
||||||
|
private ?CauseEntree $entryCause = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
#[Groups(['bovine:read'])]
|
#[Groups(['bovine:read'])]
|
||||||
#[ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
@@ -147,6 +152,11 @@ class Bovine
|
|||||||
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
private ?DateTimeImmutable $exitedAt = null;
|
private ?DateTimeImmutable $exitedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i:s'])]
|
||||||
|
private ?DateTimeImmutable $ednotifConfirmedAt = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -235,6 +245,18 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEntryCause(): ?CauseEntree
|
||||||
|
{
|
||||||
|
return $this->entryCause;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryCause(?CauseEntree $entryCause): static
|
||||||
|
{
|
||||||
|
$this->entryCause = $entryCause;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getBuilding(): ?Building
|
public function getBuilding(): ?Building
|
||||||
{
|
{
|
||||||
return $this->building;
|
return $this->building;
|
||||||
@@ -341,6 +363,18 @@ class Bovine
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEdnotifConfirmedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->ednotifConfirmedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEdnotifConfirmedAt(?DateTimeImmutable $ednotifConfirmedAt): static
|
||||||
|
{
|
||||||
|
$this->ednotifConfirmedAt = $ednotifConfirmedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getAgeMonths(): ?int
|
public function getAgeMonths(): ?int
|
||||||
{
|
{
|
||||||
return $this->ageMonths;
|
return $this->ageMonths;
|
||||||
|
|||||||
@@ -301,6 +301,32 @@ class Reception
|
|||||||
return $this->bovines->count();
|
return $this->bovines->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['reception:read'])]
|
||||||
|
public function getConfirmedBovineCount(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($this->bovines as $bovine) {
|
||||||
|
if (null !== $bovine->getEdnotifConfirmedAt()) {
|
||||||
|
++$count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['reception:read'])]
|
||||||
|
public function getDeclaredBovineCount(): int
|
||||||
|
{
|
||||||
|
$fromTypes = 0;
|
||||||
|
foreach ($this->bovines_types as $rb) {
|
||||||
|
$fromTypes += (int) ($rb->getQuantity() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromOther = is_numeric($this->bovineDetail) ? (int) $this->bovineDetail : 0;
|
||||||
|
|
||||||
|
return $fromTypes + $fromOther;
|
||||||
|
}
|
||||||
|
|
||||||
#[Groups(['reception:read'])]
|
#[Groups(['reception:read'])]
|
||||||
public function getReceptionDate(): ?DateTimeImmutable
|
public function getReceptionDate(): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
|
|||||||
27
src/Enum/CauseEntree.php
Normal file
27
src/Enum/CauseEntree.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cause d'une entrée de bovin sur l'exploitation (opération `IpBCreateEntree`).
|
||||||
|
*
|
||||||
|
* Source : `resources/ednotif-ws/CauseEntree.XSD` + doc IPG Table 9.
|
||||||
|
* Le `.value` est le code IPG transmis dans le payload SOAP.
|
||||||
|
*
|
||||||
|
* Note : duplique l'enum `Malio\EdnotifBundle\Bovin\Enum\CauseEntree` (pas
|
||||||
|
* encore présente dans la release installée v0.0.6). À remplacer par l'import
|
||||||
|
* bundle quand une version embarquant l'enum sera publiée.
|
||||||
|
*/
|
||||||
|
enum CauseEntree: string
|
||||||
|
{
|
||||||
|
/** Entrée par achat. */
|
||||||
|
case Achat = 'A';
|
||||||
|
|
||||||
|
/** Entrée par naissance. */
|
||||||
|
case Naissance = 'N';
|
||||||
|
|
||||||
|
/** Entrée par prêt ou pension. */
|
||||||
|
case PretOuPension = 'P';
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ final class BovineRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->where('b.exitedAt IS NULL')
|
->where('b.exitedAt IS NULL')
|
||||||
|
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
||||||
->orderBy('b.birthDate', 'ASC')
|
->orderBy('b.birthDate', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ final class BovineRepository extends ServiceEntityRepository
|
|||||||
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
'SUM(CASE WHEN b.ageMonths >= 20 AND b.ageMonths < 22 THEN 1 ELSE 0 END) AS between20And22',
|
||||||
)
|
)
|
||||||
->where('b.exitedAt IS NULL')
|
->where('b.exitedAt IS NULL')
|
||||||
|
->andWhere('b.ednotifConfirmedAt IS NOT NULL')
|
||||||
;
|
;
|
||||||
|
|
||||||
if (null !== $buildingCaseId) {
|
if (null !== $buildingCaseId) {
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
$this->applyEdnotifData($bovine, $animal);
|
$this->applyEdnotifData($bovine, $animal);
|
||||||
$bovine->setExitedAt(null);
|
$bovine->setExitedAt(null);
|
||||||
|
|
||||||
|
// Marque la confirmation EDNOTIF si c'est la première fois qu'on
|
||||||
|
// voit ce bovin remonter dans l'inventaire.
|
||||||
|
if (null === $bovine->getEdnotifConfirmedAt()) {
|
||||||
|
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = new DateTimeImmutable();
|
$now = new DateTimeImmutable();
|
||||||
|
|||||||
Reference in New Issue
Block a user