feat: inventaire bovins #49
@@ -19,7 +19,10 @@
|
|||||||
v-for="(item, index) in paginatedItems"
|
v-for="(item, index) in paginatedItems"
|
||||||
:key="item.id ?? index"
|
:key="item.id ?? index"
|
||||||
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
|
||||||
:class="rowClickable ? 'hover:bg-slate-50 cursor-pointer' : ''"
|
:class="[
|
||||||
|
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
|
||||||
|
rowClass ? rowClass(item) : ''
|
||||||
|
]"
|
||||||
:style="{ gridTemplateColumns: gridCols }"
|
:style="{ gridTemplateColumns: gridCols }"
|
||||||
:role="rowClickable ? 'button' : undefined"
|
:role="rowClickable ? 'button' : undefined"
|
||||||
:tabindex="rowClickable ? 0 : undefined"
|
:tabindex="rowClickable ? 0 : undefined"
|
||||||
@@ -83,12 +86,12 @@
|
|||||||
<nav aria-label="Pagination" class="flex items-center gap-1">
|
<nav aria-label="Pagination" class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||||
:disabled="currentPage <= 1"
|
:disabled="currentPage <= 1"
|
||||||
aria-label="Page précédente"
|
aria-label="Page précédente"
|
||||||
@click="goToPage(currentPage - 1)"
|
@click="goToPage(currentPage - 1)"
|
||||||
>
|
>
|
||||||
Prev
|
Précédent
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
|
||||||
@@ -113,12 +116,12 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-10 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
|
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
|
||||||
:disabled="currentPage >= totalPages"
|
:disabled="currentPage >= totalPages"
|
||||||
aria-label="Page suivante"
|
aria-label="Page suivante"
|
||||||
@click="goToPage(currentPage + 1)"
|
@click="goToPage(currentPage + 1)"
|
||||||
>
|
>
|
||||||
Next
|
Suivant
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,6 +148,7 @@ const props = withDefaults(defineProps<{
|
|||||||
showActions?: boolean
|
showActions?: boolean
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
rowClass?: (item: T) => string | undefined
|
||||||
}>(), {
|
}>(), {
|
||||||
totalItems: undefined,
|
totalItems: undefined,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -153,7 +157,8 @@ const props = withDefaults(defineProps<{
|
|||||||
rowClickable: false,
|
rowClickable: false,
|
||||||
showActions: false,
|
showActions: false,
|
||||||
emptyMessage: 'Aucune donnée',
|
emptyMessage: 'Aucune donnée',
|
||||||
loading: false
|
loading: false,
|
||||||
|
rowClass: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
108
frontend/components/ui/UiDateMaskedInput.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['flex flex-col', wrapperClass]">
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="id"
|
||||||
|
class="font-bold uppercase text-xl text-primary-700"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
v-maska="'##/##/####'"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
:value="displayValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
v-bind="attrs"
|
||||||
|
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
|
||||||
|
:class="[
|
||||||
|
sizeClass,
|
||||||
|
isEmpty ? 'text-neutral-400' : 'text-primary-700',
|
||||||
|
disabled ? 'cursor-not-allowed' : 'cursor-text',
|
||||||
|
inputClass
|
||||||
|
]"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { vMaska } from 'maska/vue'
|
||||||
|
import { computed, ref, useAttrs, watch } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
modelValue: string | null | undefined
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
size?: 'default' | 'compact'
|
||||||
|
wrapperClass?: string
|
||||||
|
labelClass?: string
|
||||||
|
inputClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: 'JJ/MM/AAAA',
|
||||||
|
disabled: false,
|
||||||
|
size: 'default',
|
||||||
|
wrapperClass: '',
|
||||||
|
labelClass: '',
|
||||||
|
inputClass: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const toDisplay = (iso: string | null | undefined): string => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const parts = iso.split('-')
|
||||||
|
if (parts.length !== 3) return ''
|
||||||
|
const [year, month, day] = parts
|
||||||
|
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toIso = (display: string): string | null => {
|
||||||
|
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const [, day, month, year] = match
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayValue = ref(toDisplay(props.modelValue))
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newIso) => {
|
||||||
|
const expected = toDisplay(newIso)
|
||||||
|
if (expected !== displayValue.value) {
|
||||||
|
displayValue.value = expected
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEmpty = computed(() => !displayValue.value)
|
||||||
|
const sizeClass = computed(() =>
|
||||||
|
props.size === 'compact'
|
||||||
|
? 'text-sm h-8 font-normal normal-case tracking-normal'
|
||||||
|
: 'text-xl py-[6px]'
|
||||||
|
)
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
displayValue.value = target.value
|
||||||
|
if (target.value === '') {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const iso = toIso(target.value)
|
||||||
|
emit('update:modelValue', iso ?? '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
|
||||||
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
|
||||||
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
|
||||||
<card-link link="/" iconName="mdi:cow">
|
<card-link link="/inventory" iconName="mdi:cow">
|
||||||
<template #label>
|
<template #label>
|
||||||
PASSEPORT<br>DU BOVIN
|
INVENTAIRE<br>BOVINS
|
||||||
</template>
|
</template>
|
||||||
</card-link>
|
</card-link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-arrivalDate>
|
<template #header-arrivalDate>
|
||||||
<UiDateInput v-model="arrivalDateFilter" size="compact" />
|
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Date d'arrivée" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-arrivalDate="{ item }">
|
<template #cell-arrivalDate="{ item }">
|
||||||
{{ formatDate(item.arrivalDate) }}
|
{{ formatDate(item.arrivalDate) }}
|
||||||
|
|||||||
217
frontend/pages/inventory.vue
Normal file
217
frontend/pages/inventory.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-[86px]">
|
||||||
|
<div class="flex items-center justify-between relative">
|
||||||
|
<div class="flex flex-row absolute -left-[60px]">
|
||||||
|
<Icon
|
||||||
|
@click="router.push('/')"
|
||||||
|
name="gg:arrow-left-o"
|
||||||
|
size="44"
|
||||||
|
class="cursor-pointer text-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
|
||||||
|
<button
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
type="button"
|
||||||
|
:disabled="syncing"
|
||||||
|
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
@click="syncInventory"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
|
||||||
|
Rafraîchir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 mb-16">
|
||||||
|
<UiDataTable
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:per-page="perPage"
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:loading="loading"
|
||||||
|
:row-class="rowClass"
|
||||||
|
>
|
||||||
|
<template #header-nationalNumber>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="filters.nationalNumber"
|
||||||
|
placeholder="N° National"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-workNumber>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="filters.workNumber"
|
||||||
|
placeholder="N° Travail"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-sex>
|
||||||
|
<UiSelect
|
||||||
|
v-model="filters.sex"
|
||||||
|
placeholder="Sexe"
|
||||||
|
:options="sexOptions"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-birthDate>
|
||||||
|
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
|
||||||
|
</template>
|
||||||
|
<template #header-breedCode>
|
||||||
|
<UiTextInput
|
||||||
|
v-model="filters.breedCode"
|
||||||
|
placeholder="Race"
|
||||||
|
size="compact"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #header-arrivalDate>
|
||||||
|
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
|
||||||
|
</template>
|
||||||
|
<template #header-buildingCase.building.label>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-buildingCase.caseNumber>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #header-age>
|
||||||
|
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
|
||||||
|
</template>
|
||||||
|
<template #cell-birthDate="{ item }">
|
||||||
|
{{ formatDate(item.birthDate) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-age="{ item }">
|
||||||
|
{{ formatAgeLabel(item.ageMonths) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-arrivalDate="{ item }">
|
||||||
|
{{ formatDate(item.arrivalDate) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-buildingCase.building.label="{ item }">
|
||||||
|
{{ item.buildingCase?.building?.label ?? '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-buildingCase.caseNumber="{ item }">
|
||||||
|
{{ item.buildingCase?.caseNumber ?? '—' }}
|
||||||
|
</template>
|
||||||
|
</UiDataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BovineData } from '~/services/dto/bovine-data'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
import { useDataTableServerState } from '~/composables/useDataTableServerState'
|
||||||
|
import { formatAgeLabel } from '~/utils/bovine-age'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const api = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
interface SyncResult {
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
exited: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncing = ref(false)
|
||||||
|
|
||||||
|
const syncInventory = async () => {
|
||||||
|
if (syncing.value) return
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
syncing.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.post<SyncResult>('bovines/sync-inventory')
|
||||||
|
toast.success({
|
||||||
|
title: 'Inventaire synchronisé',
|
||||||
|
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
|
||||||
|
})
|
||||||
|
reload()
|
||||||
|
} catch {
|
||||||
|
// error toast already handled by useApi onResponseError
|
||||||
|
} finally {
|
||||||
|
syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||||
|
useDataTableServerState<BovineData>(
|
||||||
|
'bovines',
|
||||||
|
{
|
||||||
|
'exists[exitedAt]': 'false',
|
||||||
|
nationalNumber: '',
|
||||||
|
workNumber: '',
|
||||||
|
breedCode: '',
|
||||||
|
sex: '',
|
||||||
|
'arrivalDate[after]': '',
|
||||||
|
'arrivalDate[strictly_before]': '',
|
||||||
|
'birthDate[after]': '',
|
||||||
|
'birthDate[strictly_before]': ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const sexOptions = [
|
||||||
|
{ value: 'M', label: 'Mâle' },
|
||||||
|
{ value: 'F', label: 'Femelle' }
|
||||||
|
]
|
||||||
|
|
||||||
|
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 singleDateFilter = (afterKey: string, beforeKey: string) =>
|
||||||
|
computed<string>({
|
||||||
|
get: () => (filters.value[afterKey] as string) ?? '',
|
||||||
|
set: (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
filters.value[afterKey] = ''
|
||||||
|
filters.value[beforeKey] = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filters.value[afterKey] = value
|
||||||
|
filters.value[beforeKey] = addOneDay(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
|
||||||
|
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
|
||||||
|
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
|
||||||
|
{ key: 'sex', label: 'Sexe', width: '70px' },
|
||||||
|
{ key: 'birthDate', label: 'Né le', width: '120px' },
|
||||||
|
{ key: 'age', label: 'Age', width: '110px' },
|
||||||
|
{ key: 'breedCode', label: 'Race' },
|
||||||
|
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
|
||||||
|
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
|
||||||
|
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDate = (date: string | null) => {
|
||||||
|
if (!date) return '—'
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return date
|
||||||
|
return d.toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClass = (item: BovineData): string => {
|
||||||
|
if (item.ageMonths === null || item.ageMonths === undefined) return ''
|
||||||
|
if (item.ageMonths >= 24) return 'bg-red-100 hover:bg-red-200'
|
||||||
|
if (item.ageMonths >= 22) return 'bg-orange-100 hover:bg-orange-200'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
</script>
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-receptionDate>
|
<template #header-receptionDate>
|
||||||
<UiDateInput
|
<UiDateMaskedInput
|
||||||
v-model="receptionDateFilter"
|
v-model="receptionDateFilter"
|
||||||
|
placeholder="Date"
|
||||||
size="compact"
|
size="compact"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -119,7 +120,7 @@ const receptionDateFilter = computed<string>({
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
|
||||||
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
|
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||||
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
@row-click="goToReception"
|
@row-click="goToReception"
|
||||||
>
|
>
|
||||||
<template #header-receptionDate>
|
<template #header-receptionDate>
|
||||||
<UiDateInput v-model="receptionDateFilter" size="compact" />
|
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-supplier.name>
|
<template #header-supplier.name>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
@@ -122,7 +122,7 @@ const receptionDateFilter = computed<string>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'receptionDate', label: 'Date et heure', width: '120px' },
|
{ key: 'receptionDate', label: 'Date', width: '120px' },
|
||||||
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
|
||||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||||
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
|
{ key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #header-shipmentDate>
|
<template #header-shipmentDate>
|
||||||
<UiDateInput v-model="shipmentDateFilter" size="compact" />
|
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-customer.name>
|
<template #header-customer.name>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
@row-click="goToShipment"
|
@row-click="goToShipment"
|
||||||
>
|
>
|
||||||
<template #header-shipmentDate>
|
<template #header-shipmentDate>
|
||||||
<UiDateInput v-model="shipmentDateFilter" size="compact" />
|
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-customer.name>
|
<template #header-customer.name>
|
||||||
<UiTextInput
|
<UiTextInput
|
||||||
@@ -134,7 +134,7 @@ const shipmentDateFilter = computed<string>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'shipmentDate', label: 'Date et heure', width: '120px' },
|
{ key: 'shipmentDate', label: 'Date', width: '120px' },
|
||||||
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
|
||||||
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
|
||||||
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
|
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
export interface BovineBuildingCaseRef {
|
||||||
|
caseNumber: number | null
|
||||||
|
building: { label: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BovineData {
|
export interface BovineData {
|
||||||
id: number
|
id: number
|
||||||
nationalNumber: string
|
nationalNumber: string
|
||||||
receivedWeight: number | null
|
receivedWeight: number | null
|
||||||
arrivalDate: string | null
|
arrivalDate: string | null
|
||||||
buildingCase: string | null
|
exitDate: string | null
|
||||||
|
buildingCase: BovineBuildingCaseRef | null
|
||||||
supplier: string | null
|
supplier: string | null
|
||||||
|
workNumber: string | null
|
||||||
|
birthDate: string | null
|
||||||
|
breedCode: string | null
|
||||||
|
sex: string | null
|
||||||
|
ageMonths: number | null
|
||||||
|
exitedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BovinePayload = {
|
export type BovinePayload = {
|
||||||
|
|||||||
10
frontend/utils/bovine-age.ts
Normal file
10
frontend/utils/bovine-age.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const formatAgeLabel = (months: number | null | undefined): string => {
|
||||||
|
if (months === null || months === undefined) return '—'
|
||||||
|
const years = Math.floor(months / 12)
|
||||||
|
const remaining = months % 12
|
||||||
|
let label = ''
|
||||||
|
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
|
||||||
|
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
|
||||||
|
if (!label) label = '< 1 mois'
|
||||||
|
return label
|
||||||
|
}
|
||||||
33
migrations/Version20260422155300.php
Normal file
33
migrations/Version20260422155300.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260422155300 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD sex VARCHAR(1) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD exited_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP sex');
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP exited_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260423062250.php
Normal file
31
migrations/Version20260423062250.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260423062250 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD exit_date DATE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP exit_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260424074454.php
Normal file
31
migrations/Version20260424074454.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260424074454 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine ADD age_months INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE bovine DROP age_months');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/ApiResource/BovineSyncInventoryResult.php
Normal file
42
src/ApiResource/BovineSyncInventoryResult.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
|
||||||
|
use App\State\Bovin\BovineSyncInventoryProcessor;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/bovines/sync-inventory',
|
||||||
|
openapi: new OpenApiOperation(
|
||||||
|
summary: "Synchronise l'inventaire bovin local avec EDNOTIF.",
|
||||||
|
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
|
||||||
|
tags: ['Bovines'],
|
||||||
|
),
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
input: false,
|
||||||
|
output: self::class,
|
||||||
|
processor: BovineSyncInventoryProcessor::class,
|
||||||
|
read: false,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class BovineSyncInventoryResult
|
||||||
|
{
|
||||||
|
#[ApiProperty(identifier: true)]
|
||||||
|
public string $id = 'current';
|
||||||
|
|
||||||
|
public int $created = 0;
|
||||||
|
|
||||||
|
public int $updated = 0;
|
||||||
|
|
||||||
|
public int $exited = 0;
|
||||||
|
|
||||||
|
public int $total = 0;
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
@@ -20,14 +22,19 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ORM\Table(name: 'bovine')]
|
#[ORM\Table(name: 'bovine')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: [
|
#[ApiFilter(SearchFilter::class, properties: [
|
||||||
'nationalNumber' => 'ipartial',
|
'nationalNumber' => 'ipartial',
|
||||||
|
'workNumber' => 'ipartial',
|
||||||
|
'breedCode' => 'ipartial',
|
||||||
|
'sex' => 'exact',
|
||||||
'buildingCase' => 'exact',
|
'buildingCase' => 'exact',
|
||||||
'receivedWeight' => 'exact',
|
'receivedWeight' => 'exact',
|
||||||
])]
|
])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['arrivalDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
|
||||||
|
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new Get(
|
||||||
@@ -76,6 +83,7 @@ class Bovine
|
|||||||
|
|
||||||
#[ORM\ManyToOne(inversedBy: 'bovines')]
|
#[ORM\ManyToOne(inversedBy: 'bovines')]
|
||||||
#[Groups(['bovine:read', 'bovine:write'])]
|
#[Groups(['bovine:read', 'bovine:write'])]
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
private ?BuildingCase $buildingCase = null;
|
private ?BuildingCase $buildingCase = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne]
|
#[ORM\ManyToOne]
|
||||||
@@ -95,6 +103,24 @@ class Bovine
|
|||||||
#[Groups(['bovine:read', 'building_case:read'])]
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
private ?string $breedCode = null;
|
private ?string $breedCode = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 1, nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
private ?string $sex = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
private ?int $ageMonths = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $exitDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['bovine:read', 'building_case:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $exitedAt = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -195,4 +221,66 @@ class Bovine
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getSex(): ?string
|
||||||
|
{
|
||||||
|
return $this->sex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSex(?string $sex): static
|
||||||
|
{
|
||||||
|
$this->sex = $sex;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExitDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->exitDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExitDate(?DateTimeImmutable $exitDate): static
|
||||||
|
{
|
||||||
|
$this->exitDate = $exitDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExitedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->exitedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExitedAt(?DateTimeImmutable $exitedAt): static
|
||||||
|
{
|
||||||
|
$this->exitedAt = $exitedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAgeMonths(): ?int
|
||||||
|
{
|
||||||
|
return $this->ageMonths;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAgeMonths(?int $ageMonths): static
|
||||||
|
{
|
||||||
|
$this->ageMonths = $ageMonths;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function refreshAgeMonths(): void
|
||||||
|
{
|
||||||
|
if (null === $this->birthDate) {
|
||||||
|
$this->ageMonths = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diff = $this->birthDate->diff(new DateTimeImmutable());
|
||||||
|
$this->ageMonths = ($diff->y * 12) + $diff->m;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class Building
|
|||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['building:read', 'building:summary', 'reception:read'])]
|
#[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
|
||||||
private string $label = '';
|
private string $label = '';
|
||||||
|
|
||||||
#[ORM\Column(length: 50)]
|
#[ORM\Column(length: 50)]
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class BuildingCase
|
|||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['building:read', 'building_case:read'])]
|
#[Groups(['building:read', 'building_case:read', 'bovine:read'])]
|
||||||
#[SerializedName('caseNumber')]
|
#[SerializedName('caseNumber')]
|
||||||
private ?int $case_number = null;
|
private ?int $case_number = null;
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class BuildingCase
|
|||||||
private Collection $id_case_position;
|
private Collection $id_case_position;
|
||||||
|
|
||||||
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
|
#[ORM\ManyToOne(inversedBy: 'buildingCases')]
|
||||||
#[Groups(['building_case:read'])]
|
#[Groups(['building_case:read', 'bovine:read'])]
|
||||||
#[SerializedName('building')]
|
#[SerializedName('building')]
|
||||||
private ?Building $id_building = null;
|
private ?Building $id_building = null;
|
||||||
|
|
||||||
|
|||||||
105
src/State/Bovin/BovineSyncInventoryProcessor.php
Normal file
105
src/State/Bovin/BovineSyncInventoryProcessor.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State\Bovin;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\ApiResource\BovineSyncInventoryResult;
|
||||||
|
use App\Entity\Bovine;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
|
||||||
|
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
|
||||||
|
*/
|
||||||
|
final class BovineSyncInventoryProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BovinApiInterface $bovinApi,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = [],
|
||||||
|
): BovineSyncInventoryResult {
|
||||||
|
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
|
||||||
|
|
||||||
|
$result = new BovineSyncInventoryResult();
|
||||||
|
$result->total = count($inventory->animals);
|
||||||
|
|
||||||
|
$existingByNationalNumber = [];
|
||||||
|
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
|
||||||
|
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
foreach ($inventory->animals as $animal) {
|
||||||
|
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
|
||||||
|
if (null === $nationalNumber || '' === $nationalNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$nationalNumber] = true;
|
||||||
|
|
||||||
|
if (isset($existingByNationalNumber[$nationalNumber])) {
|
||||||
|
$bovine = $existingByNationalNumber[$nationalNumber];
|
||||||
|
++$result->updated;
|
||||||
|
} else {
|
||||||
|
$bovine = new Bovine();
|
||||||
|
$bovine->setNationalNumber($nationalNumber);
|
||||||
|
$this->em->persist($bovine);
|
||||||
|
++$result->created;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyEdnotifData($bovine, $animal);
|
||||||
|
$bovine->setExitedAt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
|
||||||
|
if (isset($seen[$nationalNumber])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (null !== $bovine->getExitedAt()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bovine->setExitedAt($now);
|
||||||
|
++$result->exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
|
||||||
|
{
|
||||||
|
$identification = $animal->identification;
|
||||||
|
if (null !== $identification) {
|
||||||
|
$bovine->setSex($identification->sex);
|
||||||
|
$bovine->setBreedCode($identification->breedType);
|
||||||
|
$bovine->setWorkNumber($identification->workNumber);
|
||||||
|
$bovine->setBirthDate($identification->birthDate?->date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestEntry = null;
|
||||||
|
$latestExit = null;
|
||||||
|
foreach ($animal->presencePeriods as $period) {
|
||||||
|
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
|
||||||
|
$latestEntry = $period->entry->date;
|
||||||
|
}
|
||||||
|
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
|
||||||
|
$latestExit = $period->exit->date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$bovine->setArrivalDate($latestEntry);
|
||||||
|
$bovine->setExitDate($latestExit);
|
||||||
|
$bovine->refreshAgeMonths();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user