feat : Reception.validatedAt + statut entrées + mode consultation

- Backend : champ Reception.validatedAt (timestamp) avec PreUpdate + helpers
  isFullyConfirmed/tryValidate ; sync EDNOTIF déclenche tryValidate sur
  les receptions impactées ; expose Supplier.name dans le groupe bovine:read.
- Migration : ajout colonne validated_at sans backfill (les receptions
  remontent en attente jusqu'au prochain sync).
- Front /entry-exit : remplace Historique par 'Entrées validées' (filtre
  exists[validatedAt]=true), ajoute filtres et colonne Statut sur les
  deux tableaux, retire Fournisseur, layout 2x2 (entrées + sorties).
- Front /entry-exit/entry/{id} : mode consultation quand entryCompleted=true
  (formulaire + actions masqués, colonne EDNOTIF par bovin) ; ajoute
  colonnes Vendeur et Cause dans le récap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 16:31:16 +02:00
parent 476502c91c
commit 8f88abab46
9 changed files with 400 additions and 240 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-start gap-6 relative">
<div class="flex items-center justify-start gap-6 relative" :class="{ 'mb-8': isConsultationMode }">
<Icon
@click="router.push('/entry-exit')"
name="gg:arrow-left-o"
@@ -13,10 +13,11 @@
</h1>
</div>
</div>
<p class="text-sm text-slate-600 mt-1 mb-8">
<p v-if="!isConsultationMode" class="text-sm text-slate-600 mt-1 mb-8">
{{ reception?.supplier?.name ?? '' }} · Bovins déclarés : {{ declaredCount }} · Bovins saisis : {{ savedBovinesTotal }}
</p>
<template v-if="!isConsultationMode">
<form
class="grid grid-cols-4 gap-x-16 gap-y-8 mb-12 items-end"
:class="{ submitted }"
@@ -36,37 +37,13 @@
<UiDateMaskedInput
v-model="form.arrivalDate"
label="Date d'entrée"
required
/>
<UiSelect
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
/>
<UiSelect
v-model="form.buildingId"
label="Bâtiment"
:options="buildingOptions"
/>
<UiSelect
v-model="form.caseId"
label="Case"
:options="caseOptions"
:disabled="!form.buildingId"
/>
<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"
required
/>
</form>
@@ -81,6 +58,7 @@
Ajouter
</UiButton>
</div>
</template>
<UiDataTable
v-model:page="recapPage"
@@ -89,29 +67,67 @@
:items="savedBovines"
:total-items="savedBovinesTotal"
:loading="savedBovinesLoading"
:show-actions="true"
:show-actions="!isConsultationMode"
>
<template #header-nationalNumber>
<UiTextInput v-model="recapFilters.nationalNumber" placeholder="N° National" size="compact" />
</template>
<template #header-workNumber>
<UiTextInput v-model="recapFilters.workNumber" placeholder="N° Travail" size="compact" />
</template>
<template #header-bovineType.label>
<UiTextInput v-model="recapFilters['bovineType.label']" placeholder="Race" size="compact" />
</template>
<template #header-sex>
<UiTextInput v-model="recapFilters.sex" placeholder="Sexe" size="compact" />
</template>
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" placeholder="Né le" size="compact" />
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Entrée le" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput :model-value="''" placeholder="Vendeur" size="compact" disabled />
</template>
<template #header-entryCause>
<UiTextInput :model-value="''" placeholder="Cause" size="compact" disabled />
</template>
<template #header-ednotifConfirmedAt>
<UiTextInput :model-value="''" placeholder="EDNOTIF" size="compact" disabled />
</template>
<template #header-actions>
<UiTextInput :model-value="''" placeholder="Action" size="compact" disabled />
</template>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-finalPrice="{ item }">
{{ formatPrice(item.finalPrice) }}
</template>
<template #cell-pricePerKg="{ item }">
{{ formatPrice(item.pricePerKg) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.effectiveBuilding?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
<template #cell-bovineType.label="{ item }">
{{ item.bovineType?.label ?? '—' }}
</template>
<template #cell-supplier.name="{ item }">
{{ supplierName(item.supplier) }}
</template>
<template #cell-entryCause="{ item }">
{{ entryCauseLabel(item.entryCause) }}
</template>
<template #cell-ednotifConfirmedAt="{ item }">
<span
v-if="item.ednotifConfirmedAt"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-700"
>
Validé
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
>
En attente
</span>
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
@@ -122,7 +138,7 @@
</template>
</UiDataTable>
<div class="flex justify-center mt-8">
<div v-if="!isConsultationMode" class="flex justify-center mt-8">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] px-8"
@@ -140,9 +156,7 @@
import type { ReceptionData } from '~/services/dto/reception-data'
import type { BovineData } from '~/services/dto/bovine-data'
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()
@@ -153,38 +167,95 @@ const receptionId = computed(() => Number(route.params.id))
const reception = ref<ReceptionData | null>(null)
const suppliers = ref<SupplierData[]>([])
const buildings = ref<BuildingData[]>([])
const {
items: savedBovines,
totalItems: savedBovinesTotal,
page: recapPage,
perPage: recapPerPage,
filters: recapFilters,
loading: savedBovinesLoading,
reload: reloadSavedBovines
} = useDataTableServerState<BovineData>(
'bovines',
{ reception: receptionId.value },
{ initialPerPage: 50 }
{
reception: receptionId.value,
nationalNumber: '',
workNumber: '',
'bovineType.label': '',
sex: '',
'birthDate[after]': '',
'birthDate[strictly_before]': '',
'arrivalDate[after]': '',
'arrivalDate[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 birthDateFilter = computed<string>({
get: () => (recapFilters.value['birthDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
recapFilters.value['birthDate[after]'] = ''
recapFilters.value['birthDate[strictly_before]'] = ''
return
}
recapFilters.value['birthDate[after]'] = value
recapFilters.value['birthDate[strictly_before]'] = addOneDay(value)
}
})
const arrivalDateFilter = computed<string>({
get: () => (recapFilters.value['arrivalDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
recapFilters.value['arrivalDate[after]'] = ''
recapFilters.value['arrivalDate[strictly_before]'] = ''
return
}
recapFilters.value['arrivalDate[after]'] = value
recapFilters.value['arrivalDate[strictly_before]'] = addOneDay(value)
}
})
const isAdding = ref(false)
const isValidating = ref(false)
const submitted = ref(false)
const recapColumns = [
{ key: 'nationalNumber', label: 'N° National', width: '110px' },
{ key: 'workNumber', label: 'N° Travail', width: '90px' },
{ key: 'bovineType.label', label: 'Race', width: '110px' },
{ key: 'sex', label: 'Sexe', width: '60px' },
{ key: 'birthDate', label: 'Né le', width: '90px' },
{ key: 'receivedWeight', label: 'Poids', width: '70px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '90px' },
{ key: 'pricePerKg', label: 'Prix/kg', width: '80px' },
{ key: 'finalPrice', label: 'Prix total', width: '90px' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '60px' }
const isConsultationMode = computed(() => reception.value?.entryCompleted === true)
const recapColumns = computed(() => {
const cols: Array<{ key: string; label: string; width: string }> = [
{ key: 'nationalNumber', label: 'N° National', width: '100px' },
{ key: 'workNumber', label: 'N° Travail', width: '110px' },
{ key: 'bovineType.label', label: 'Race', width: '1fr' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '75px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '75px' },
{ key: 'supplier.name', label: 'Vendeur', width: '150px' },
{ key: 'entryCause', label: 'Cause', width: '100px' }
]
if (isConsultationMode.value) {
cols.push({ key: 'ednotifConfirmedAt', label: 'EDNOTIF', width: '110px' })
}
return cols
})
const entryCauseLabel = (code: string | null | undefined) => {
if (!code) return '—'
return entryCauseOptions.find(o => o.value === code)?.label ?? code
}
const supplierName = (supplier: BovineData['supplier']) => {
if (supplier && typeof supplier === 'object') return supplier.name
return '—'
}
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
@@ -193,11 +264,6 @@ const formatDate = (date: string | null | undefined) => {
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const formatPrice = (price: number | null | undefined) => {
if (price === null || price === undefined) return '—'
return `${price.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
const confirmDeleteBovine = async (bovine: BovineData) => {
const confirmed = window.confirm(`Supprimer le bovin ${bovine.nationalNumber} ?`)
if (!confirmed) return
@@ -231,10 +297,6 @@ interface FormState {
entryCause: EntryCause
arrivalDate: string
supplierId: string | number | null
buildingId: string | number | null
caseId: string | number | null
receivedWeight: number | null
pricePerKg: number | null
}
const entryCauseOptions = [
@@ -247,11 +309,7 @@ const initialForm = (): FormState => ({
nationalNumber: '',
entryCause: 'A',
arrivalDate: reception.value?.receptionDate?.slice(0, 10) ?? '',
supplierId: reception.value?.supplier?.id ?? null,
buildingId: reception.value?.buildings?.[0]?.id ?? null,
caseId: null,
receivedWeight: null,
pricePerKg: null
supplierId: reception.value?.supplier?.id ?? null
})
const form = reactive<FormState>(initialForm())
@@ -260,30 +318,13 @@ const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: s.id, label: s.name }))
)
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(form.buildingId))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(() => form.buildingId, (newVal, oldVal) => {
if (newVal !== oldVal) form.caseId = null
})
const declaredCount = computed(() => reception.value?.declaredBovineCount ?? 0)
const isFormValid = computed(() =>
form.nationalNumber.trim() !== ''
&& !!form.entryCause
&& !!form.arrivalDate
&& form.supplierId !== null
)
const resetForm = () => {
@@ -306,16 +347,13 @@ const addBovine = async () => {
isAdding.value = true
try {
const payload: Record<string, unknown> = {
const payload = {
nationalNumber: form.nationalNumber.trim(),
entryCause: form.entryCause,
arrivalDate: form.arrivalDate,
supplier: `/api/suppliers/${form.supplierId}`,
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' }
@@ -332,10 +370,7 @@ const addBovine = async () => {
}
onMounted(async () => {
[suppliers.value, buildings.value] = await Promise.all([
getSupplierList(),
getBuildingList()
])
suppliers.value = await getSupplierList()
await loadReception()
reloadSavedBovines()
})

View File

@@ -23,6 +23,25 @@
row-clickable
@row-click="goToEntry"
>
<template #header-identificationNumber>
<UiTextInput
v-model="entryFilters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateMaskedInput v-model="entryDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-declaredCount>
<UiTextInput :model-value="''" placeholder="Déclarés" size="compact" disabled />
</template>
<template #header-registeredBovineCount>
<UiTextInput :model-value="''" placeholder="Saisis" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
</template>
<template #cell-identificationNumber="{ item }">
{{ item.identificationNumber }}
</template>
@@ -35,49 +54,48 @@
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-status="{ item }">
<span
v-if="!item.entryCompleted"
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-yellow-100 text-yellow-700"
>
Attente saisie
</span>
<span
v-else
class="inline-block rounded px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700"
>
Attente EDNOTIF
</span>
</template>
</UiDataTable>
</section>
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
À venir
</div>
</section>
</div>
<section class="mt-12 mb-16">
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Historique</h2>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Entrées validées</h2>
<UiDataTable
v-model:page="historyPage"
v-model:per-page="historyPerPage"
:columns="historyColumns"
:items="history"
:total-items="totalHistory"
:loading="historyLoading"
v-model:page="validatedPage"
v-model:per-page="validatedPerPage"
:columns="validatedColumns"
:items="validated"
:total-items="totalValidated"
:loading="validatedLoading"
>
<template #header-identificationNumber>
<UiTextInput
v-model="historyFilters.identificationNumber"
v-model="validatedFilters.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"
/>
<UiDateMaskedInput v-model="validatedDateFilter" placeholder="Date" 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 #header-validatedAt>
<UiTextInput :model-value="''" placeholder="Validée le" size="compact" disabled />
</template>
<template #header-status>
<UiTextInput :model-value="''" placeholder="Statut" size="compact" disabled />
@@ -91,26 +109,36 @@
<template #cell-registeredBovineCount="{ item }">
{{ item.registeredBovineCount ?? 0 }}
</template>
<template #cell-confirmedBovineCount="{ item }">
{{ item.confirmedBovineCount ?? 0 }} / {{ item.registeredBovineCount ?? 0 }}
<template #cell-validatedAt="{ item }">
{{ formatDate(item.validatedAt) }}
</template>
<template #cell-status="{ item }">
<template #cell-status>
<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
Validée
</span>
</template>
</UiDataTable>
</section>
</div>
<div class="mt-12 mb-16 grid grid-cols-2 gap-8">
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties en attente</h2>
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
À venir
</div>
</section>
<section>
<h2 class="text-xl font-bold uppercase text-primary-500 mb-4">Sorties validées</h2>
<div class="rounded border border-dashed border-slate-300 p-8 text-center text-slate-500">
À venir
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
@@ -124,38 +152,41 @@ const {
totalItems: totalEntries,
page: entryPage,
perPage: entryPerPage,
filters: entryFilters,
loading: entriesLoading,
reload
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'entryCompleted': 'false',
'receptionType.code': 'BOVINS'
'exists[validatedAt]': 'false',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 5 }
)
const {
items: history,
totalItems: totalHistory,
page: historyPage,
perPage: historyPerPage,
filters: historyFilters,
loading: historyLoading,
reload: reloadHistory
items: validated,
totalItems: totalValidated,
page: validatedPage,
perPage: validatedPerPage,
filters: validatedFilters,
loading: validatedLoading,
reload: reloadValidated
} = useDataTableServerState<ReceptionData>(
'receptions',
{
'isValid': 'true',
'entryCompleted': 'true',
'exists[validatedAt]': 'true',
'receptionType.code': 'BOVINS',
'identificationNumber': '',
'supplier.name': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 10 }
{ initialPerPage: 5 }
)
const addOneDay = (dateString: string): string => {
@@ -164,37 +195,49 @@ const addOneDay = (dateString: string): string => {
return next.toISOString().slice(0, 10)
}
const historyDateFilter = computed<string>({
get: () => (historyFilters.value['receptionDate[after]'] as string) ?? '',
const entryDateFilter = computed<string>({
get: () => (entryFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
historyFilters.value['receptionDate[after]'] = ''
historyFilters.value['receptionDate[strictly_before]'] = ''
entryFilters.value['receptionDate[after]'] = ''
entryFilters.value['receptionDate[strictly_before]'] = ''
return
}
historyFilters.value['receptionDate[after]'] = value
historyFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
entryFilters.value['receptionDate[after]'] = value
entryFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const validatedDateFilter = computed<string>({
get: () => (validatedFilters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
validatedFilters.value['receptionDate[after]'] = ''
validatedFilters.value['receptionDate[strictly_before]'] = ''
return
}
validatedFilters.value['receptionDate[after]'] = value
validatedFilters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const entryColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '80px' },
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ 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' }
{ key: 'declaredCount', label: 'Déclarés', width: '75px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '70px' },
{ key: 'status', label: 'Statut', width: '1fr' }
]
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 validatedColumns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date', width: '75px' },
{ key: 'registeredBovineCount', label: 'Saisis', width: '50px' },
{ key: 'validatedAt', label: 'Validée le', width: '75px' },
{ key: 'status', label: 'Statut', width: '1fr' }
]
const formatDate = (date: string | null) => {
const formatDate = (date: string | null | undefined) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
@@ -211,6 +254,6 @@ const goToEntry = (reception: ReceptionData) => {
onMounted(() => {
reload()
reloadHistory()
reloadValidated()
})
</script>

View File

@@ -18,7 +18,7 @@ export interface BovineData {
buildingCase: BovineBuildingCaseRef | null
building: BovineBuildingRef | null
effectiveBuilding: BovineBuildingRef | null
supplier: string | null
supplier: { id: number; name: string } | string | null
workNumber: string | null
birthDate: string | null
bovineType: { id: number; label: string; code: string } | null

View File

@@ -19,6 +19,7 @@ export interface ReceptionData {
currentStep: number
isValid: boolean
entryCompleted?: boolean
validatedAt?: string | null
registeredBovineCount?: number
confirmedBovineCount?: number
declaredBovineCount?: number

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260430090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajout de reception.validated_at (timestamp de validation complète : entrée terminée + tous bovins confirmés EDNOTIF).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE reception ADD validated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE reception DROP validated_at');
}
}

View File

@@ -118,6 +118,7 @@ class Bovine
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[ApiProperty(readableLink: true)]
private ?Supplier $supplier = null;
#[ORM\Column(length: 50, nullable: true)]

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
@@ -32,6 +33,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid', 'entryCompleted'])]
#[ApiFilter(ExistsFilter::class, properties: ['validatedAt'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
@@ -115,6 +117,10 @@ class Reception
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
private bool $entryCompleted = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['reception:read', 'reception-bovine:read'])]
private ?DateTimeImmutable $validatedAt = null;
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context(
@@ -295,6 +301,45 @@ class Reception
return $this;
}
public function getValidatedAt(): ?DateTimeImmutable
{
return $this->validatedAt;
}
public function setValidatedAt(?DateTimeImmutable $validatedAt): self
{
$this->validatedAt = $validatedAt;
return $this;
}
public function isFullyConfirmed(): bool
{
if ($this->bovines->isEmpty()) {
return false;
}
foreach ($this->bovines as $bovine) {
if (null === $bovine->getEdnotifConfirmedAt()) {
return false;
}
}
return true;
}
public function tryValidate(): void
{
if ($this->entryCompleted && null === $this->validatedAt && $this->isFullyConfirmed()) {
$this->validatedAt = new DateTimeImmutable();
}
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->tryValidate();
}
#[Groups(['reception:read'])]
public function getRegisteredBovineCount(): int
{

View File

@@ -54,11 +54,11 @@ class Supplier
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['supplier:read', 'reception:read'])]
#[Groups(['supplier:read', 'reception:read', 'bovine:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
#[Groups(['supplier:read', 'reception:read', 'supplier:write', 'bovine:read'])]
private string $name = '';
#[ORM\Column(length: 180, nullable: true)]

View File

@@ -53,6 +53,7 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
}
$seen = [];
$impactedReceptions = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
@@ -77,8 +78,16 @@ final class BovineSyncInventoryProcessor implements ProcessorInterface
// voit ce bovin remonter dans l'inventaire.
if (null === $bovine->getEdnotifConfirmedAt()) {
$bovine->setEdnotifConfirmedAt(new DateTimeImmutable());
$reception = $bovine->getReception();
if (null !== $reception) {
$impactedReceptions[$reception->getId()] = $reception;
}
}
}
foreach ($impactedReceptions as $reception) {
$reception->tryValidate();
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {